Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9daac28d | ||
|
|
a6917146b8 | ||
|
|
f7ad3aac95 | ||
|
|
5a7722d1d2 | ||
|
|
393c29bd2d | ||
|
|
9c0893c512 | ||
|
|
ae34b4856a |
@@ -30,6 +30,35 @@ jobs:
|
||||
with:
|
||||
install-go: false
|
||||
|
||||
web-ui:
|
||||
name: Web UI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: web/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Check that the embedded web/dist is up to date
|
||||
run: |
|
||||
git diff --exit-code -- web/dist ||
|
||||
(echo "::error::web/dist is stale, run 'make web' and commit the result" && exit 1)
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -24,6 +24,17 @@ jobs:
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: web/frontend/package-lock.json
|
||||
|
||||
- name: Build web UI
|
||||
run: npm ci && npm run build
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
@@ -31,7 +42,8 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
mkdir -p build
|
||||
go build -o build/OpenGFW-${GOOS}-${GOARCH} -ldflags "-s -w" .
|
||||
go build -o build/OpenGFW-${GOOS}-${GOARCH} \
|
||||
-ldflags "-s -w -X github.com/apernet/OpenGFW/cmd.appVersion=${GITHUB_REF_NAME} -X github.com/apernet/OpenGFW/cmd.appCommit=${GITHUB_SHA::7}" .
|
||||
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
+10
@@ -208,3 +208,13 @@ $RECYCLE.BIN/
|
||||
|
||||
# Internal tools not ready for public use yet
|
||||
tools/flowseq/
|
||||
|
||||
### Geo databases downloaded at runtime ###
|
||||
geoip.dat
|
||||
geosite.dat
|
||||
|
||||
### Web UI ###
|
||||
# The built UI in web/dist IS committed, since it is embedded into the binary.
|
||||
web/frontend/node_modules/
|
||||
web/frontend/*.tsbuildinfo
|
||||
web/frontend/.vite/
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
GO ?= go
|
||||
NPM ?= npm
|
||||
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
LDFLAGS := -s -w \
|
||||
-X github.com/apernet/OpenGFW/cmd.appVersion=$(VERSION) \
|
||||
-X github.com/apernet/OpenGFW/cmd.appCommit=$(COMMIT)
|
||||
|
||||
FRONTEND := web/frontend
|
||||
|
||||
.PHONY: all
|
||||
all: web build
|
||||
|
||||
## build: build the OpenGFW binary (embeds the web UI in web/dist)
|
||||
.PHONY: build
|
||||
build:
|
||||
$(GO) build -o OpenGFW -ldflags "$(LDFLAGS)" .
|
||||
|
||||
## web: build the web UI into web/dist
|
||||
.PHONY: web
|
||||
web:
|
||||
cd $(FRONTEND) && $(NPM) install && $(NPM) run build
|
||||
|
||||
## web-dev: run the Vite dev server against a local OpenGFW instance
|
||||
.PHONY: web-dev
|
||||
web-dev:
|
||||
cd $(FRONTEND) && $(NPM) run dev
|
||||
|
||||
## devserver: serve the web UI with synthetic data (works on any OS)
|
||||
.PHONY: devserver
|
||||
devserver:
|
||||
$(GO) run ./web/devserver
|
||||
|
||||
.PHONY: test
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
.PHONY: vet
|
||||
vet:
|
||||
$(GO) vet ./...
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm -f OpenGFW
|
||||
rm -rf $(FRONTEND)/node_modules
|
||||
|
||||
.PHONY: install
|
||||
install: build
|
||||
@echo "Installing OpenGFW to /usr/local/bin"
|
||||
@install -m 755 OpenGFW /usr/local/bin/OpenGFW || (echo "install failed: try running as root or use sudo" && exit 1)
|
||||
@echo "Installing systemd unit to /etc/systemd/system/opengfw.service"
|
||||
@printf '%s\n' \
|
||||
'[Unit]' \
|
||||
'Description=OpenGFW' \
|
||||
'After=network.target' \
|
||||
'' \
|
||||
'[Service]' \
|
||||
'ExecStart=/usr/local/bin/OpenGFW -c /etc/opengfw/config.yaml /etc/opengfw/rules.yaml' \
|
||||
'Restart=on-failure' \
|
||||
'User=root' \
|
||||
'Group=root' \
|
||||
'WorkingDirectory=/etc/opengfw' \
|
||||
'' \
|
||||
'[Install]' \
|
||||
'WantedBy=multi-user.target' \
|
||||
> /tmp/opengfw.service || (echo "write failed: try running as root or use sudo" && exit 1)
|
||||
|
||||
@echo "Installing default config to /etc/opengfw/"
|
||||
@mkdir -p /etc/opengfw || (echo "mkdir failed: try running as root or use sudo" && exit 1)
|
||||
@install -m 644 config.yaml /etc/opengfw/config.yaml || (echo "copy config failed: try running as root or use sudo" && exit 1)
|
||||
@install -m 644 rules.yaml /etc/opengfw/rules.yaml || (echo "copy rules failed: try running as root or use sudo" && exit 1)
|
||||
@mv /tmp/opengfw.service /etc/systemd/system/opengfw.service || (echo "move failed: try running as root or use sudo" && exit 1)
|
||||
@echo "Reloading systemd daemon and enabling service"
|
||||
@systemctl daemon-reload || (echo "daemon-reload failed: ensure systemd is available" && exit 1)
|
||||
@systemctl enable --now opengfw.service || (echo "enabling service failed: try running as root or use sudo" && exit 1)
|
||||
+26
-1
@@ -29,7 +29,32 @@ Telegram グループ: https://t.me/OpGFW
|
||||
- ルールのホットリロード (`SIGHUP` を送信してリロード)
|
||||
- 柔軟なアナライザ&モディファイアフレームワーク
|
||||
- 拡張可能な IO 実装 (今のところ NFQueue のみ)
|
||||
- [WIP] ウェブ UI
|
||||
- リアルタイムのトラフィックダッシュボードとルールエディタを備えたウェブ UI
|
||||
|
||||
## ウェブ UI
|
||||
|
||||
OpenGFW にはオプションのウェブダッシュボードが同梱されています。リアルタイムのトラフィック統計、
|
||||
アナライザのプロパティを含むイベントフィード、そしてルールを検証してホットリロードできるルールエディタが利用できます。
|
||||
スマートフォンでもデスクトップでも使えるレスポンシブ対応です。
|
||||
|
||||
ルールは条件のリストとして作成できます。フィールド(SNI、DNS 名、HTTP Host、IP、ポート、検出されたプロトコル)と
|
||||
条件(ワイルドカード、CIDR、GeoIP の国、GeoSite カテゴリ、範囲、正規表現)、そして 1 つ以上の値を選ぶだけです。
|
||||
もちろん expr 式や YAML を直接編集することもできます。
|
||||
|
||||
設定ファイルで有効にします:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
listen: :8080
|
||||
secret: パスワード # 空の場合は起動時にランダム生成されログに出力されます
|
||||
# cert: /path/to/fullchain.pem
|
||||
# key: /path/to/privkey.pem
|
||||
```
|
||||
|
||||
UI はバイナリに埋め込まれているため、追加のファイルを配置する必要はありません。
|
||||
パスワードを知る人はルールを変更できるため、信頼できるネットワークにのみ公開してください。
|
||||
開発方法は [web/README.md](web/README.md) を参照してください。
|
||||
|
||||
## ユースケース
|
||||
|
||||
|
||||
@@ -33,7 +33,33 @@ Telegram group: https://t.me/OpGFW
|
||||
- Hot-reloadable rules (send `SIGHUP` to reload)
|
||||
- Flexible analyzer & modifier framework
|
||||
- Extensible IO implementation (only NFQueue for now)
|
||||
- [WIP] Web UI
|
||||
- Web UI with live traffic dashboard and rule editor
|
||||
|
||||
## Web UI
|
||||
|
||||
OpenGFW ships with an optional web dashboard: live traffic statistics, a real-time event
|
||||
feed with per-stream analyzer properties, and a rule editor that validates and hot reloads
|
||||
the ruleset. It works on phones as well as on desktops.
|
||||
|
||||
Rules can be written as a list of conditions — pick a field (SNI, DNS name, HTTP host,
|
||||
IP, port, detected protocol), an operator (wildcard, CIDR, GeoIP country, GeoSite
|
||||
category, range, regex) and one or more values — or as raw expr expressions and YAML.
|
||||
Countries and site categories are picked from the geo databases the engine loaded.
|
||||
|
||||
Enable it in your config file:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
listen: :8080
|
||||
secret: your-password-here # generated and logged on startup if left empty
|
||||
# cert: /path/to/fullchain.pem
|
||||
# key: /path/to/privkey.pem
|
||||
```
|
||||
|
||||
The UI is embedded in the binary, so no extra files need to be deployed. Only expose it to
|
||||
trusted networks — anyone with the password can change your rules. See
|
||||
[web/README.md](web/README.md) for development instructions.
|
||||
|
||||
## Use cases
|
||||
|
||||
|
||||
+24
-1
@@ -29,7 +29,30 @@ Telegram 群组: https://t.me/OpGFW
|
||||
- 规则可以热重载 (发送 `SIGHUP` 信号)
|
||||
- 灵活的协议解析和修改框架
|
||||
- 可扩展的 IO 实现 (目前只有 NFQueue)
|
||||
- [开发中] Web UI
|
||||
- Web UI,包含实时流量面板与规则编辑器
|
||||
|
||||
## Web UI
|
||||
|
||||
OpenGFW 内置了一个可选的 Web 控制面板:实时流量统计、带解析器属性的实时事件流,以及可以校验并热加载规则的规则编辑器。
|
||||
界面同时适配手机和桌面浏览器。
|
||||
|
||||
规则既可以用条件列表来写——选择字段(SNI、DNS 域名、HTTP Host、IP、端口、识别到的协议)、
|
||||
条件(通配符、CIDR、GeoIP 国家/地区、GeoSite 分类、端口区间、正则)以及一个或多个值——
|
||||
也可以直接编写 expr 表达式或 YAML。国家/地区与站点分类直接从引擎加载的 geo 数据库中选择。
|
||||
|
||||
在配置文件中启用:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
listen: :8080
|
||||
secret: 你的密码 # 留空则启动时随机生成并打印到日志
|
||||
# cert: /path/to/fullchain.pem
|
||||
# key: /path/to/privkey.pem
|
||||
```
|
||||
|
||||
前端已经打包进二进制文件,无需额外部署静态资源。请只在可信网络中开放该端口——拿到密码即可修改你的规则。
|
||||
开发说明见 [web/README.md](web/README.md)。
|
||||
|
||||
## 使用场景
|
||||
|
||||
|
||||
+75
-27
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/apernet/OpenGFW/modifier"
|
||||
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||
"github.com/apernet/OpenGFW/ruleset"
|
||||
"github.com/apernet/OpenGFW/web"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
@@ -36,6 +37,12 @@ const (
|
||||
appLogFormatEnv = "OPENGFW_LOG_FORMAT"
|
||||
)
|
||||
|
||||
// Build information, set with -ldflags at build time.
|
||||
var (
|
||||
appVersion = "dev"
|
||||
appCommit = ""
|
||||
)
|
||||
|
||||
var logger *zap.Logger
|
||||
|
||||
// Flags
|
||||
@@ -165,6 +172,7 @@ type cliConfig struct {
|
||||
IO cliConfigIO `mapstructure:"io"`
|
||||
Workers cliConfigWorkers `mapstructure:"workers"`
|
||||
Ruleset cliConfigRuleset `mapstructure:"ruleset"`
|
||||
Web cliConfigWeb `mapstructure:"web"`
|
||||
}
|
||||
|
||||
type cliConfigIO struct {
|
||||
@@ -188,6 +196,14 @@ type cliConfigRuleset struct {
|
||||
GeoSite string `mapstructure:"geosite"`
|
||||
}
|
||||
|
||||
type cliConfigWeb struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Listen string `mapstructure:"listen"`
|
||||
Secret string `mapstructure:"secret"`
|
||||
Cert string `mapstructure:"cert"`
|
||||
Key string `mapstructure:"key"`
|
||||
}
|
||||
|
||||
func (c *cliConfig) fillLogger(config *engine.Config) error {
|
||||
config.Logger = &engineLogger{}
|
||||
return nil
|
||||
@@ -204,7 +220,7 @@ func (c *cliConfig) fillIO(config *engine.Config) error {
|
||||
if err != nil {
|
||||
return configError{Field: "io", Err: err}
|
||||
}
|
||||
config.IOs = []io.PacketIO{nfio}
|
||||
config.IO = nfio
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -247,24 +263,26 @@ func runMain(cmd *cobra.Command, args []string) {
|
||||
if err != nil {
|
||||
logger.Fatal("failed to parse config", zap.Error(err))
|
||||
}
|
||||
defer func() {
|
||||
// Make sure to close all IOs on exit
|
||||
for _, i := range engineConfig.IOs {
|
||||
_ = i.Close()
|
||||
}
|
||||
}()
|
||||
defer engineConfig.IO.Close() // Make sure to close IO on exit
|
||||
|
||||
// Statistics hub for the web UI
|
||||
if config.Web.Enabled {
|
||||
hub = web.NewHub()
|
||||
}
|
||||
|
||||
// Ruleset
|
||||
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
|
||||
rsConfig := &ruleset.BuiltinConfig{
|
||||
Logger: &rulesetLogger{},
|
||||
GeoSiteFilename: config.Ruleset.GeoSite,
|
||||
GeoIpFilename: config.Ruleset.GeoIp,
|
||||
ProtectedDialContext: engineConfig.IO.ProtectedDialContext,
|
||||
}
|
||||
rm := newRuleManager(args[0], analyzers, modifiers, rsConfig)
|
||||
rawRules, err := os.ReadFile(args[0])
|
||||
if err != nil {
|
||||
logger.Fatal("failed to load rules", zap.Error(err))
|
||||
}
|
||||
rsConfig := &ruleset.BuiltinConfig{
|
||||
Logger: &rulesetLogger{},
|
||||
GeoSiteFilename: config.Ruleset.GeoSite,
|
||||
GeoIpFilename: config.Ruleset.GeoIp,
|
||||
}
|
||||
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
|
||||
rs, _, err := rm.Compile(string(rawRules))
|
||||
if err != nil {
|
||||
logger.Fatal("failed to compile rules", zap.Error(err))
|
||||
}
|
||||
@@ -275,6 +293,7 @@ func runMain(cmd *cobra.Command, args []string) {
|
||||
if err != nil {
|
||||
logger.Fatal("failed to initialize engine", zap.Error(err))
|
||||
}
|
||||
rm.SetEngine(en)
|
||||
|
||||
// Signal handling
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
@@ -293,25 +312,21 @@ func runMain(cmd *cobra.Command, args []string) {
|
||||
for {
|
||||
<-reloadChan
|
||||
logger.Info("reloading rules")
|
||||
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
|
||||
if err != nil {
|
||||
logger.Error("failed to load rules, using old rules", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
|
||||
if err != nil {
|
||||
logger.Error("failed to compile rules, using old rules", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
err = en.UpdateRuleset(rs)
|
||||
if err != nil {
|
||||
logger.Error("failed to update ruleset", zap.Error(err))
|
||||
if err := rm.Reload(); err != nil {
|
||||
logger.Error("failed to reload rules, using old rules", zap.Error(err))
|
||||
} else {
|
||||
logger.Info("rules reloaded")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Web UI
|
||||
if config.Web.Enabled {
|
||||
if err := startWebServer(ctx, &config, rm); err != nil {
|
||||
logger.Fatal("failed to start web UI", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
logger.Info("engine started")
|
||||
logger.Info("engine exited", zap.Error(en.Run(ctx)))
|
||||
}
|
||||
@@ -319,14 +334,23 @@ func runMain(cmd *cobra.Command, args []string) {
|
||||
type engineLogger struct{}
|
||||
|
||||
func (l *engineLogger) WorkerStart(id int) {
|
||||
if hub != nil {
|
||||
hub.WorkerStarted()
|
||||
}
|
||||
logger.Debug("worker started", zap.Int("id", id))
|
||||
}
|
||||
|
||||
func (l *engineLogger) WorkerStop(id int) {
|
||||
if hub != nil {
|
||||
hub.WorkerStopped()
|
||||
}
|
||||
logger.Debug("worker stopped", zap.Int("id", id))
|
||||
}
|
||||
|
||||
func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||
if hub != nil {
|
||||
hub.StreamNew("tcp")
|
||||
}
|
||||
logger.Debug("new TCP stream",
|
||||
zap.Int("workerID", workerID),
|
||||
zap.Int64("id", info.ID),
|
||||
@@ -335,6 +359,9 @@ func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||
}
|
||||
|
||||
func (l *engineLogger) TCPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
|
||||
if hub != nil {
|
||||
hub.PropUpdate(toWebProps(info.Props))
|
||||
}
|
||||
logger.Debug("TCP stream property update",
|
||||
zap.Int64("id", info.ID),
|
||||
zap.String("src", info.SrcString()),
|
||||
@@ -344,6 +371,9 @@ func (l *engineLogger) TCPStreamPropUpdate(info ruleset.StreamInfo, close bool)
|
||||
}
|
||||
|
||||
func (l *engineLogger) TCPStreamAction(info ruleset.StreamInfo, action ruleset.Action, noMatch bool) {
|
||||
if hub != nil {
|
||||
hub.StreamAction(hubInfo(info), action.String())
|
||||
}
|
||||
logger.Info("TCP stream action",
|
||||
zap.Int64("id", info.ID),
|
||||
zap.String("src", info.SrcString()),
|
||||
@@ -353,6 +383,9 @@ func (l *engineLogger) TCPStreamAction(info ruleset.StreamInfo, action ruleset.A
|
||||
}
|
||||
|
||||
func (l *engineLogger) UDPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||
if hub != nil {
|
||||
hub.StreamNew("udp")
|
||||
}
|
||||
logger.Debug("new UDP stream",
|
||||
zap.Int("workerID", workerID),
|
||||
zap.Int64("id", info.ID),
|
||||
@@ -361,6 +394,9 @@ func (l *engineLogger) UDPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||
}
|
||||
|
||||
func (l *engineLogger) UDPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
|
||||
if hub != nil {
|
||||
hub.PropUpdate(toWebProps(info.Props))
|
||||
}
|
||||
logger.Debug("UDP stream property update",
|
||||
zap.Int64("id", info.ID),
|
||||
zap.String("src", info.SrcString()),
|
||||
@@ -370,6 +406,9 @@ func (l *engineLogger) UDPStreamPropUpdate(info ruleset.StreamInfo, close bool)
|
||||
}
|
||||
|
||||
func (l *engineLogger) UDPStreamAction(info ruleset.StreamInfo, action ruleset.Action, noMatch bool) {
|
||||
if hub != nil {
|
||||
hub.StreamAction(hubInfo(info), action.String())
|
||||
}
|
||||
logger.Info("UDP stream action",
|
||||
zap.Int64("id", info.ID),
|
||||
zap.String("src", info.SrcString()),
|
||||
@@ -379,6 +418,9 @@ func (l *engineLogger) UDPStreamAction(info ruleset.StreamInfo, action ruleset.A
|
||||
}
|
||||
|
||||
func (l *engineLogger) ModifyError(info ruleset.StreamInfo, err error) {
|
||||
if hub != nil {
|
||||
hub.Error(hubInfo(info), "", err.Error())
|
||||
}
|
||||
logger.Error("modify error",
|
||||
zap.Int64("id", info.ID),
|
||||
zap.String("src", info.SrcString()),
|
||||
@@ -410,6 +452,9 @@ func (l *engineLogger) AnalyzerErrorf(streamID int64, name string, format string
|
||||
type rulesetLogger struct{}
|
||||
|
||||
func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
|
||||
if hub != nil {
|
||||
hub.RuleLog(hubInfo(info), name)
|
||||
}
|
||||
logger.Info("ruleset log",
|
||||
zap.String("name", name),
|
||||
zap.Int64("id", info.ID),
|
||||
@@ -419,6 +464,9 @@ func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
|
||||
}
|
||||
|
||||
func (l *rulesetLogger) MatchError(info ruleset.StreamInfo, name string, err error) {
|
||||
if hub != nil {
|
||||
hub.Error(hubInfo(info), name, err.Error())
|
||||
}
|
||||
logger.Error("ruleset match error",
|
||||
zap.String("name", name),
|
||||
zap.Int64("id", info.ID),
|
||||
|
||||
+320
@@ -0,0 +1,320 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/apernet/OpenGFW/analyzer"
|
||||
"github.com/apernet/OpenGFW/engine"
|
||||
"github.com/apernet/OpenGFW/modifier"
|
||||
"github.com/apernet/OpenGFW/ruleset"
|
||||
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
|
||||
"github.com/apernet/OpenGFW/web"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// hub is the (optional) statistics collector feeding the web UI. It is nil
|
||||
// when the web UI is disabled.
|
||||
var hub *web.Hub
|
||||
|
||||
// ruleManager owns the rule file and knows how to hot reload the engine.
|
||||
// It is shared between the SIGHUP handler and the web UI.
|
||||
type ruleManager struct {
|
||||
path string
|
||||
analyzers []analyzer.Analyzer
|
||||
modifiers []modifier.Modifier
|
||||
rsConfig *ruleset.BuiltinConfig
|
||||
|
||||
mu sync.Mutex
|
||||
engine engine.Engine
|
||||
}
|
||||
|
||||
var _ web.RuleManager = (*ruleManager)(nil)
|
||||
|
||||
func newRuleManager(path string, ans []analyzer.Analyzer, mods []modifier.Modifier, rsConfig *ruleset.BuiltinConfig) *ruleManager {
|
||||
return &ruleManager{path: path, analyzers: ans, modifiers: mods, rsConfig: rsConfig}
|
||||
}
|
||||
|
||||
func (m *ruleManager) SetEngine(en engine.Engine) {
|
||||
m.mu.Lock()
|
||||
m.engine = en
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *ruleManager) Path() string { return m.path }
|
||||
|
||||
// Compile parses and compiles a rule file content, without applying it.
|
||||
func (m *ruleManager) Compile(raw string) (ruleset.Ruleset, []ruleset.ExprRule, error) {
|
||||
rawRs, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse rules: %w", err)
|
||||
}
|
||||
rs, err := ruleset.CompileExprRules(rawRs, m.analyzers, m.modifiers, m.rsConfig)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to compile rules: %w", err)
|
||||
}
|
||||
return rs, rawRs, nil
|
||||
}
|
||||
|
||||
// Reload re-reads the rule file from disk and applies it to the engine.
|
||||
func (m *ruleManager) Reload() error {
|
||||
bs, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rs, _, err := m.Compile(string(bs))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.update(rs)
|
||||
}
|
||||
|
||||
func (m *ruleManager) update(rs ruleset.Ruleset) error {
|
||||
m.mu.Lock()
|
||||
en := m.engine
|
||||
m.mu.Unlock()
|
||||
if en == nil {
|
||||
return errors.New("engine is not running")
|
||||
}
|
||||
return en.UpdateRuleset(rs)
|
||||
}
|
||||
|
||||
func (m *ruleManager) Load() (string, []web.Rule, error) {
|
||||
bs, err := os.ReadFile(m.path)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
rules, err := ruleset.ExprRulesFromYAMLBytes(bs)
|
||||
if err != nil {
|
||||
// The file is still shown as-is so that the user can fix it in the editor.
|
||||
return string(bs), nil, nil
|
||||
}
|
||||
return string(bs), toWebRules(rules), nil
|
||||
}
|
||||
|
||||
func (m *ruleManager) Validate(raw string) ([]web.Rule, error) {
|
||||
_, rules, err := m.Compile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toWebRules(rules), nil
|
||||
}
|
||||
|
||||
func (m *ruleManager) Marshal(rules []web.Rule) (string, error) {
|
||||
out := make([]web.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.Modifier != nil && r.Modifier.Name == "" {
|
||||
r.Modifier = nil // Leftover from switching a rule away from `modify`
|
||||
}
|
||||
out = append(out, r)
|
||||
}
|
||||
bs, err := yaml.Marshal(out)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(bs), nil
|
||||
}
|
||||
|
||||
// Apply compiles the given rules, persists them to the rule file and hot
|
||||
// reloads the engine. The file is only written once the rules compile.
|
||||
func (m *ruleManager) Apply(raw string) ([]web.Rule, error) {
|
||||
rs, rules, err := m.Compile(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := m.writeFile(raw); err != nil {
|
||||
return nil, fmt.Errorf("failed to write rule file: %w", err)
|
||||
}
|
||||
if err := m.update(rs); err != nil {
|
||||
return nil, fmt.Errorf("failed to update ruleset: %w", err)
|
||||
}
|
||||
return toWebRules(rules), nil
|
||||
}
|
||||
|
||||
// writeFile replaces the rule file atomically so that a crash in the middle of
|
||||
// a save cannot leave a truncated ruleset behind.
|
||||
func (m *ruleManager) writeFile(raw string) error {
|
||||
mode := os.FileMode(0o644)
|
||||
if fi, err := os.Stat(m.path); err == nil {
|
||||
mode = fi.Mode().Perm()
|
||||
}
|
||||
dir := filepath.Dir(m.path)
|
||||
tmp, err := os.CreateTemp(dir, ".rules-*.yaml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName) // No-op once the rename succeeded
|
||||
if _, err := tmp.WriteString(raw); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
tmp.Close()
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Chmod(tmpName, mode); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmpName, m.path)
|
||||
}
|
||||
|
||||
func toWebRules(rules []ruleset.ExprRule) []web.Rule {
|
||||
out := make([]web.Rule, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
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}
|
||||
}
|
||||
out = append(out, wr)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// startWebServer starts the web UI. It returns nil when the UI is disabled.
|
||||
func startWebServer(ctx context.Context, config *cliConfig, rm *ruleManager) error {
|
||||
secret := config.Web.Secret
|
||||
generated := false
|
||||
if secret == "" {
|
||||
secret = web.RandomSecret()
|
||||
generated = true
|
||||
}
|
||||
srv, err := web.NewServer(web.Config{
|
||||
Listen: config.Web.Listen,
|
||||
Secret: secret,
|
||||
CertFile: config.Web.Cert,
|
||||
KeyFile: config.Web.Key,
|
||||
Hub: hub,
|
||||
Rules: rm,
|
||||
Meta: webMeta(),
|
||||
Info: func() web.Info { return webInfo(config, rm) },
|
||||
Geo: func() web.GeoData { return webGeoData(config) },
|
||||
Logf: func(format string, args ...interface{}) {
|
||||
logger.Info(fmt.Sprintf(format, args...))
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return configError{Field: "web", Err: err}
|
||||
}
|
||||
scheme := "http"
|
||||
if srv.TLS() {
|
||||
scheme = "https"
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("listen", srv.Addr()),
|
||||
zap.String("scheme", scheme),
|
||||
}
|
||||
if generated {
|
||||
fields = append(fields, zap.String("password", secret))
|
||||
logger.Warn("web UI password was not set, using a generated one", fields...)
|
||||
} else {
|
||||
logger.Info("web UI started", fields...)
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
logger.Error("web UI stopped", zap.Error(err))
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func webMeta() web.MetaInfo {
|
||||
meta := web.MetaInfo{
|
||||
Actions: []string{"allow", "block", "drop", "modify"},
|
||||
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||
}
|
||||
for _, a := range analyzers {
|
||||
proto := "tcp"
|
||||
if _, ok := a.(analyzer.UDPAnalyzer); ok {
|
||||
proto = "udp"
|
||||
}
|
||||
meta.Analyzers = append(meta.Analyzers, web.AnalyzerInfo{Name: a.Name(), Proto: proto})
|
||||
}
|
||||
for _, m := range modifiers {
|
||||
meta.Modifiers = append(meta.Modifiers, m.Name())
|
||||
}
|
||||
return meta
|
||||
}
|
||||
|
||||
func webInfo(config *cliConfig, rm *ruleManager) web.Info {
|
||||
hostname, _ := os.Hostname()
|
||||
return web.Info{
|
||||
Version: appVersion,
|
||||
Commit: appCommit,
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
Hostname: hostname,
|
||||
RuleFile: rm.Path(),
|
||||
Config: web.ConfigDigest{
|
||||
IOQueueSize: config.IO.QueueSize,
|
||||
IOLocal: config.IO.Local,
|
||||
IORST: config.IO.RST,
|
||||
Workers: config.Workers.Count,
|
||||
WorkerQueue: config.Workers.QueueSize,
|
||||
UDPMaxStreams: config.Workers.UDPMaxStreams,
|
||||
GeoIP: config.Ruleset.GeoIp,
|
||||
GeoSite: config.Ruleset.GeoSite,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// webGeoData lists what the configured geo databases contain, so that the rule
|
||||
// builder can offer a picker instead of a free text field. Loading the files is
|
||||
// expensive, but the web server only calls this on demand and caches the result.
|
||||
func webGeoData(config *cliConfig) web.GeoData {
|
||||
matcher := geo.NewGeoMatcher(config.Ruleset.GeoSite, config.Ruleset.GeoIp)
|
||||
var data web.GeoData
|
||||
if entries, err := matcher.ListGeoIP(); err != nil {
|
||||
data.IPError = err.Error()
|
||||
} else {
|
||||
for _, e := range entries {
|
||||
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
|
||||
}
|
||||
}
|
||||
if entries, err := matcher.ListGeoSite(); err != nil {
|
||||
data.SiteError = err.Error()
|
||||
} else {
|
||||
for _, e := range entries {
|
||||
data.Site = append(data.Site, web.GeoEntry{
|
||||
Code: e.Code,
|
||||
Count: e.Domains,
|
||||
Attributes: e.Attributes,
|
||||
})
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// hubInfo converts engine stream info into the shape the hub understands.
|
||||
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
|
||||
return web.StreamInfo{
|
||||
ID: info.ID,
|
||||
Proto: info.Protocol.String(),
|
||||
SrcIP: info.SrcIP.String(),
|
||||
SrcPort: info.SrcPort,
|
||||
DstIP: info.DstIP.String(),
|
||||
DstPort: info.DstPort,
|
||||
Props: toWebProps(info.Props),
|
||||
}
|
||||
}
|
||||
|
||||
func toWebProps(props analyzer.CombinedPropMap) web.Props {
|
||||
if len(props) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(web.Props, len(props))
|
||||
for name, p := range props {
|
||||
out[name] = p
|
||||
}
|
||||
return out
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
io:
|
||||
queueSize: 1024
|
||||
queueNum: 100
|
||||
table: opengfw
|
||||
connMarkAccept: 1001
|
||||
connMarkDrop: 1002
|
||||
rcvBuf: 4194304
|
||||
sndBuf: 4194304
|
||||
local: true
|
||||
rst: false
|
||||
|
||||
workers:
|
||||
count: 4
|
||||
queueSize: 64
|
||||
tcpMaxBufferedPagesTotal: 65536
|
||||
tcpMaxBufferedPagesPerConn: 16
|
||||
tcpTimeout: 10m
|
||||
udpMaxStreams: 4096
|
||||
|
||||
# 指定的 geoip/geosite 档案路径
|
||||
# 如果未设置,将自动从 https://github.com/Loyalsoldier/v2ray-rules-dat 下载
|
||||
# ruleset:
|
||||
# geoip: geoip.dat
|
||||
# geosite: geosite.dat
|
||||
|
||||
replay:
|
||||
realtime: false
|
||||
+15
-19
@@ -15,7 +15,7 @@ var _ Engine = (*engine)(nil)
|
||||
|
||||
type engine struct {
|
||||
logger Logger
|
||||
ioList []io.PacketIO
|
||||
io io.PacketIO
|
||||
workers []*worker
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewEngine(config Config) (Engine, error) {
|
||||
}
|
||||
return &engine{
|
||||
logger: config.Logger,
|
||||
ioList: config.IOs,
|
||||
io: config.IO,
|
||||
workers: workers,
|
||||
}, nil
|
||||
}
|
||||
@@ -58,27 +58,24 @@ func (e *engine) UpdateRuleset(r ruleset.Ruleset) error {
|
||||
|
||||
func (e *engine) Run(ctx context.Context) error {
|
||||
ioCtx, ioCancel := context.WithCancel(ctx)
|
||||
defer ioCancel() // Stop workers & IOs
|
||||
defer ioCancel() // Stop workers & IO
|
||||
|
||||
// Start workers
|
||||
for _, w := range e.workers {
|
||||
go w.Run(ioCtx)
|
||||
}
|
||||
|
||||
// Register callbacks
|
||||
errChan := make(chan error, len(e.ioList))
|
||||
for _, i := range e.ioList {
|
||||
ioEntry := i // Make sure dispatch() uses the correct ioEntry
|
||||
err := ioEntry.Register(ioCtx, func(p io.Packet, err error) bool {
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
return false
|
||||
}
|
||||
return e.dispatch(ioEntry, p)
|
||||
})
|
||||
// Register IO callback
|
||||
errChan := make(chan error, 1)
|
||||
err := e.io.Register(ioCtx, func(p io.Packet, err error) bool {
|
||||
if err != nil {
|
||||
return err
|
||||
errChan <- err
|
||||
return false
|
||||
}
|
||||
return e.dispatch(p)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Block until IO errors or context is cancelled
|
||||
@@ -91,8 +88,7 @@ func (e *engine) Run(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// dispatch dispatches a packet to a worker.
|
||||
// This must be safe for concurrent use, as it may be called from multiple IOs.
|
||||
func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
||||
func (e *engine) dispatch(p io.Packet) bool {
|
||||
data := p.Data()
|
||||
ipVersion := data[0] >> 4
|
||||
var layerType gopacket.LayerType
|
||||
@@ -102,7 +98,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
||||
layerType = layers.LayerTypeIPv6
|
||||
} else {
|
||||
// Unsupported network layer
|
||||
_ = ioEntry.SetVerdict(p, io.VerdictAcceptStream, nil)
|
||||
_ = e.io.SetVerdict(p, io.VerdictAcceptStream, nil)
|
||||
return true
|
||||
}
|
||||
// Load balance by stream ID
|
||||
@@ -112,7 +108,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
||||
StreamID: p.StreamID(),
|
||||
Packet: packet,
|
||||
SetVerdict: func(v io.Verdict, b []byte) error {
|
||||
return ioEntry.SetVerdict(p, v, b)
|
||||
return e.io.SetVerdict(p, v, b)
|
||||
},
|
||||
})
|
||||
return true
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ type Engine interface {
|
||||
// Config is the configuration for the engine.
|
||||
type Config struct {
|
||||
Logger Logger
|
||||
IOs []io.PacketIO
|
||||
IO io.PacketIO
|
||||
Ruleset ruleset.Ruleset
|
||||
|
||||
Workers int // Number of workers. Zero or negative means auto (number of CPU cores).
|
||||
|
||||
@@ -5,7 +5,7 @@ go 1.21
|
||||
require (
|
||||
github.com/bwmarrin/snowflake v0.3.0
|
||||
github.com/coreos/go-iptables v0.7.0
|
||||
github.com/expr-lang/expr v1.15.7
|
||||
github.com/expr-lang/expr v1.16.3
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf
|
||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
|
||||
@@ -7,8 +7,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/expr-lang/expr v1.15.7 h1:BK0JcWUkoW6nrbLBo6xCKhz4BvH5DSOOu1Gx5lucyZo=
|
||||
github.com/expr-lang/expr v1.15.7/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
|
||||
github.com/expr-lang/expr v1.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to=
|
||||
github.com/expr-lang/expr v1.16.3/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf h1:NqGS3vTHzVENbIfd87cXZwdpO6MB2R1PjHMJLi4Z3ow=
|
||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
|
||||
+5
-1
@@ -2,6 +2,7 @@ package io
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
type Verdict int
|
||||
@@ -29,7 +30,6 @@ type Packet interface {
|
||||
|
||||
// PacketCallback is called for each packet received.
|
||||
// Return false to "unregister" and stop receiving packets.
|
||||
// It must be safe for concurrent use.
|
||||
type PacketCallback func(Packet, error) bool
|
||||
|
||||
type PacketIO interface {
|
||||
@@ -39,6 +39,10 @@ type PacketIO interface {
|
||||
Register(context.Context, PacketCallback) error
|
||||
// SetVerdict sets the verdict for a packet.
|
||||
SetVerdict(Packet, Verdict, []byte) error
|
||||
// ProtectedDialContext is like net.DialContext, but the connection is "protected"
|
||||
// in the sense that the packets sent/received through the connection must bypass
|
||||
// the packet IO and not be processed by the callback.
|
||||
ProtectedDialContext(ctx context.Context, network, address string) (net.Conn, error)
|
||||
// Close closes the packet IO.
|
||||
Close() error
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/coreos/go-iptables/iptables"
|
||||
"github.com/florianl/go-nfqueue"
|
||||
@@ -50,6 +52,7 @@ func generateNftRules(local, rst bool) (*nftTableSpec, error) {
|
||||
}
|
||||
for i := range table.Chains {
|
||||
c := &table.Chains[i]
|
||||
c.Rules = append(c.Rules, "meta mark $ACCEPT_CTMARK ct mark set $ACCEPT_CTMARK") // Bypass protected connections
|
||||
c.Rules = append(c.Rules, "ct mark $ACCEPT_CTMARK counter accept")
|
||||
if rst {
|
||||
c.Rules = append(c.Rules, "ip protocol tcp ct mark $DROP_CTMARK counter reject with tcp reset")
|
||||
@@ -72,6 +75,8 @@ func generateIptRules(local, rst bool) ([]iptRule, error) {
|
||||
}
|
||||
rules := make([]iptRule, 0, 4*len(chains))
|
||||
for _, chain := range chains {
|
||||
// Bypass protected connections
|
||||
rules = append(rules, iptRule{"filter", chain, []string{"-m", "mark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "CONNMARK", "--set-mark", strconv.Itoa(nfqueueConnMarkAccept)}})
|
||||
rules = append(rules, iptRule{"filter", chain, []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}})
|
||||
if rst {
|
||||
rules = append(rules, iptRule{"filter", chain, []string{"-p", "tcp", "-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "REJECT", "--reject-with", "tcp-reset"}})
|
||||
@@ -96,6 +101,8 @@ type nfqueuePacketIO struct {
|
||||
// iptables not nil = use iptables instead of nftables
|
||||
ipt4 *iptables.IPTables
|
||||
ipt6 *iptables.IPTables
|
||||
|
||||
protectedDialer *net.Dialer
|
||||
}
|
||||
|
||||
type NFQueuePacketIOConfig struct {
|
||||
@@ -153,6 +160,18 @@ func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
|
||||
rst: config.RST,
|
||||
ipt4: ipt4,
|
||||
ipt6: ipt6,
|
||||
protectedDialer: &net.Dialer{
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
var err error
|
||||
cErr := c.Control(func(fd uintptr) {
|
||||
err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, nfqueueConnMarkAccept)
|
||||
})
|
||||
if cErr != nil {
|
||||
return cErr
|
||||
}
|
||||
return err
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -239,6 +258,10 @@ func (n *nfqueuePacketIO) SetVerdict(p Packet, v Verdict, newPacket []byte) erro
|
||||
}
|
||||
}
|
||||
|
||||
func (n *nfqueuePacketIO) ProtectedDialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return n.protectedDialer.DialContext(ctx, network, address)
|
||||
}
|
||||
|
||||
func (n *nfqueuePacketIO) Close() error {
|
||||
if n.rSet {
|
||||
if n.ipt4 != nil {
|
||||
|
||||
@@ -2,6 +2,7 @@ package geo
|
||||
|
||||
import (
|
||||
"net"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -14,14 +15,12 @@ type GeoMatcher struct {
|
||||
ipMatcherLock sync.Mutex
|
||||
}
|
||||
|
||||
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) (*GeoMatcher, error) {
|
||||
geoLoader := NewDefaultGeoLoader(geoSiteFilename, geoIpFilename)
|
||||
|
||||
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) *GeoMatcher {
|
||||
return &GeoMatcher{
|
||||
geoLoader: geoLoader,
|
||||
geoLoader: NewDefaultGeoLoader(geoSiteFilename, geoIpFilename),
|
||||
geoSiteMatcher: make(map[string]hostMatcher),
|
||||
geoIpMatcher: make(map[string]hostMatcher),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (g *GeoMatcher) MatchGeoIp(ip, condition string) bool {
|
||||
@@ -99,6 +98,69 @@ func (g *GeoMatcher) LoadGeoSite() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// GeoIPEntry describes one entry of a GeoIP database.
|
||||
type GeoIPEntry struct {
|
||||
// Code is the lowercase key to pass to geoip(), usually a country code.
|
||||
Code string
|
||||
// CIDRs is the number of networks the entry covers.
|
||||
CIDRs int
|
||||
}
|
||||
|
||||
// GeoSiteEntry describes one entry of a GeoSite database.
|
||||
type GeoSiteEntry struct {
|
||||
// Code is the lowercase key to pass to geosite().
|
||||
Code string
|
||||
// Attributes are the suffixes usable as `code@attribute`.
|
||||
Attributes []string
|
||||
// Domains is the number of domain rules the entry covers.
|
||||
Domains int
|
||||
}
|
||||
|
||||
// ListGeoIP returns every entry of the GeoIP database, sorted by code.
|
||||
// It is meant for UIs that let the user pick a country instead of typing one.
|
||||
func (g *GeoMatcher) ListGeoIP() ([]GeoIPEntry, error) {
|
||||
gMap, err := g.geoLoader.LoadGeoIP()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]GeoIPEntry, 0, len(gMap))
|
||||
for code, list := range gMap {
|
||||
entries = append(entries, GeoIPEntry{Code: code, CIDRs: len(list.GetCidr())})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// ListGeoSite returns every entry of the GeoSite database, sorted by code,
|
||||
// including the attributes each entry supports.
|
||||
func (g *GeoMatcher) ListGeoSite() ([]GeoSiteEntry, error) {
|
||||
gMap, err := g.geoLoader.LoadGeoSite()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make([]GeoSiteEntry, 0, len(gMap))
|
||||
for code, list := range gMap {
|
||||
attrSet := make(map[string]struct{})
|
||||
for _, domain := range list.GetDomain() {
|
||||
for _, attr := range domain.GetAttribute() {
|
||||
attrSet[strings.ToLower(attr.GetKey())] = struct{}{}
|
||||
}
|
||||
}
|
||||
attrs := make([]string, 0, len(attrSet))
|
||||
for attr := range attrSet {
|
||||
attrs = append(attrs, attr)
|
||||
}
|
||||
sort.Strings(attrs)
|
||||
entries = append(entries, GeoSiteEntry{
|
||||
Code: code,
|
||||
Attributes: attrs,
|
||||
Domains: len(list.GetDomain()),
|
||||
})
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (g *GeoMatcher) LoadGeoIP() error {
|
||||
_, err := g.geoLoader.LoadGeoIP()
|
||||
return err
|
||||
|
||||
+126
-69
@@ -1,11 +1,15 @@
|
||||
package ruleset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/expr-lang/expr/builtin"
|
||||
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/ast"
|
||||
@@ -38,8 +42,13 @@ func ExprRulesFromYAML(file string) ([]ExprRule, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ExprRulesFromYAMLBytes(bs)
|
||||
}
|
||||
|
||||
// ExprRulesFromYAMLBytes parses expression rules from a YAML document in memory.
|
||||
func ExprRulesFromYAMLBytes(bs []byte) ([]ExprRule, error) {
|
||||
var rules []ExprRule
|
||||
err = yaml.Unmarshal(bs, &rules)
|
||||
err := yaml.Unmarshal(bs, &rules)
|
||||
return rules, err
|
||||
}
|
||||
|
||||
@@ -55,10 +64,9 @@ type compiledExprRule struct {
|
||||
var _ Ruleset = (*exprRuleset)(nil)
|
||||
|
||||
type exprRuleset struct {
|
||||
Rules []compiledExprRule
|
||||
Ans []analyzer.Analyzer
|
||||
Logger Logger
|
||||
GeoMatcher *geo.GeoMatcher
|
||||
Rules []compiledExprRule
|
||||
Ans []analyzer.Analyzer
|
||||
Logger Logger
|
||||
}
|
||||
|
||||
func (r *exprRuleset) Analyzers(info StreamInfo) []analyzer.Analyzer {
|
||||
@@ -100,10 +108,7 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
||||
fullAnMap := analyzersToMap(ans)
|
||||
fullModMap := modifiersToMap(mods)
|
||||
depAnMap := make(map[string]analyzer.Analyzer)
|
||||
geoMatcher, err := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
funcMap := buildFunctionMap(config)
|
||||
// Compile all rules and build a map of analyzers that are used by the rules.
|
||||
for _, rule := range rules {
|
||||
if rule.Action == "" && !rule.Log {
|
||||
@@ -118,13 +123,19 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
||||
action = &a
|
||||
}
|
||||
visitor := &idVisitor{Variables: make(map[string]bool), Identifiers: make(map[string]bool)}
|
||||
patcher := &idPatcher{}
|
||||
patcher := &idPatcher{FuncMap: funcMap}
|
||||
program, err := expr.Compile(rule.Expr,
|
||||
func(c *conf.Config) {
|
||||
c.Strict = false
|
||||
c.Expect = reflect.Bool
|
||||
c.Visitors = append(c.Visitors, visitor, patcher)
|
||||
registerBuiltinFunctions(c.Functions, geoMatcher)
|
||||
for name, f := range funcMap {
|
||||
c.Functions[name] = &builtin.Function{
|
||||
Name: name,
|
||||
Func: f.Func,
|
||||
Types: f.Types,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
@@ -138,24 +149,15 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
||||
if isBuiltInAnalyzer(name) || visitor.Variables[name] {
|
||||
continue
|
||||
}
|
||||
// Check if it's one of the built-in functions, and if so,
|
||||
// skip it as an analyzer & do initialization if necessary.
|
||||
switch name {
|
||||
case "geoip":
|
||||
if err := geoMatcher.LoadGeoIP(); err != nil {
|
||||
return nil, fmt.Errorf("rule %q failed to load geoip: %w", rule.Name, err)
|
||||
}
|
||||
case "geosite":
|
||||
if err := geoMatcher.LoadGeoSite(); err != nil {
|
||||
return nil, fmt.Errorf("rule %q failed to load geosite: %w", rule.Name, err)
|
||||
}
|
||||
case "cidr":
|
||||
// No initialization needed for CIDR.
|
||||
default:
|
||||
a, ok := fullAnMap[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rule %q uses unknown analyzer %q", rule.Name, name)
|
||||
if f, ok := funcMap[name]; ok {
|
||||
// Built-in function, initialize if necessary
|
||||
if f.InitFunc != nil {
|
||||
if err := f.InitFunc(); err != nil {
|
||||
return nil, fmt.Errorf("rule %q failed to initialize function %q: %w", rule.Name, name, err)
|
||||
}
|
||||
}
|
||||
} else if a, ok := fullAnMap[name]; ok {
|
||||
// Analyzer, add to dependency map
|
||||
depAnMap[name] = a
|
||||
}
|
||||
}
|
||||
@@ -184,37 +186,12 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
||||
depAns = append(depAns, a)
|
||||
}
|
||||
return &exprRuleset{
|
||||
Rules: compiledRules,
|
||||
Ans: depAns,
|
||||
Logger: config.Logger,
|
||||
GeoMatcher: geoMatcher,
|
||||
Rules: compiledRules,
|
||||
Ans: depAns,
|
||||
Logger: config.Logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func registerBuiltinFunctions(funcMap map[string]*ast.Function, geoMatcher *geo.GeoMatcher) {
|
||||
funcMap["geoip"] = &ast.Function{
|
||||
Name: "geoip",
|
||||
Func: func(params ...any) (any, error) {
|
||||
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
|
||||
}
|
||||
funcMap["geosite"] = &ast.Function{
|
||||
Name: "geosite",
|
||||
Func: func(params ...any) (any, error) {
|
||||
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
|
||||
}
|
||||
funcMap["cidr"] = &ast.Function{
|
||||
Name: "cidr",
|
||||
Func: func(params ...any) (any, error) {
|
||||
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf((func(string, string) bool)(nil)), reflect.TypeOf(builtins.MatchCIDR)},
|
||||
}
|
||||
}
|
||||
|
||||
func streamInfoToExprEnv(info StreamInfo) map[string]interface{} {
|
||||
m := map[string]interface{}{
|
||||
"id": info.ID,
|
||||
@@ -299,29 +276,109 @@ func (v *idVisitor) Visit(node *ast.Node) {
|
||||
// idPatcher patches the AST during expr compilation, replacing certain values with
|
||||
// their internal representations for better runtime performance.
|
||||
type idPatcher struct {
|
||||
Err error
|
||||
FuncMap map[string]*Function
|
||||
Err error
|
||||
}
|
||||
|
||||
func (p *idPatcher) Visit(node *ast.Node) {
|
||||
switch (*node).(type) {
|
||||
case *ast.CallNode:
|
||||
callNode := (*node).(*ast.CallNode)
|
||||
if callNode.Func == nil {
|
||||
if callNode.Callee == nil {
|
||||
// Ignore invalid call nodes
|
||||
return
|
||||
}
|
||||
switch callNode.Func.Name {
|
||||
case "cidr":
|
||||
cidrStringNode, ok := callNode.Arguments[1].(*ast.StringNode)
|
||||
if !ok {
|
||||
return
|
||||
if f, ok := p.FuncMap[callNode.Callee.String()]; ok {
|
||||
if f.PatchFunc != nil {
|
||||
if err := f.PatchFunc(&callNode.Arguments); err != nil {
|
||||
p.Err = err
|
||||
return
|
||||
}
|
||||
}
|
||||
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
|
||||
if err != nil {
|
||||
p.Err = err
|
||||
return
|
||||
}
|
||||
callNode.Arguments[1] = &ast.ConstantNode{Value: cidr}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Function struct {
|
||||
InitFunc func() error
|
||||
PatchFunc func(args *[]ast.Node) error
|
||||
Func func(params ...any) (any, error)
|
||||
Types []reflect.Type
|
||||
}
|
||||
|
||||
func buildFunctionMap(config *BuiltinConfig) map[string]*Function {
|
||||
geoMatcher := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
|
||||
return map[string]*Function{
|
||||
"geoip": {
|
||||
InitFunc: geoMatcher.LoadGeoIP,
|
||||
PatchFunc: nil,
|
||||
Func: func(params ...any) (any, error) {
|
||||
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
|
||||
},
|
||||
"geosite": {
|
||||
InitFunc: geoMatcher.LoadGeoSite,
|
||||
PatchFunc: nil,
|
||||
Func: func(params ...any) (any, error) {
|
||||
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
|
||||
},
|
||||
"cidr": {
|
||||
InitFunc: nil,
|
||||
PatchFunc: func(args *[]ast.Node) error {
|
||||
cidrStringNode, ok := (*args)[1].(*ast.StringNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("cidr: invalid argument type")
|
||||
}
|
||||
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
(*args)[1] = &ast.ConstantNode{Value: cidr}
|
||||
return nil
|
||||
},
|
||||
Func: func(params ...any) (any, error) {
|
||||
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
|
||||
},
|
||||
Types: []reflect.Type{reflect.TypeOf(builtins.MatchCIDR)},
|
||||
},
|
||||
"lookup": {
|
||||
InitFunc: nil,
|
||||
PatchFunc: func(args *[]ast.Node) error {
|
||||
var serverStr *ast.StringNode
|
||||
if len(*args) > 1 {
|
||||
// Has the optional server argument
|
||||
var ok bool
|
||||
serverStr, ok = (*args)[1].(*ast.StringNode)
|
||||
if !ok {
|
||||
return fmt.Errorf("lookup: invalid argument type")
|
||||
}
|
||||
}
|
||||
r := &net.Resolver{
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
if serverStr != nil {
|
||||
address = serverStr.Value
|
||||
}
|
||||
return config.ProtectedDialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
if len(*args) > 1 {
|
||||
(*args)[1] = &ast.ConstantNode{Value: r}
|
||||
} else {
|
||||
*args = append(*args, &ast.ConstantNode{Value: r})
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Func: func(params ...any) (any, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
return params[1].(*net.Resolver).LookupHost(ctx, params[0].(string))
|
||||
},
|
||||
Types: []reflect.Type{
|
||||
reflect.TypeOf((func(string, *net.Resolver) []string)(nil)),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package ruleset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/apernet/OpenGFW/analyzer"
|
||||
"github.com/apernet/OpenGFW/analyzer/tcp"
|
||||
"github.com/apernet/OpenGFW/analyzer/udp"
|
||||
"github.com/apernet/OpenGFW/modifier"
|
||||
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||
)
|
||||
|
||||
// builderExpressions are the canonical expressions produced by the visual rule
|
||||
// builder of the web UI (see web/frontend/src/lib/rule/compile.ts). They are
|
||||
// pinned here so that a change to the expression language, the analyzers or the
|
||||
// built-in functions cannot silently break the builder.
|
||||
var builderExpressions = []string{
|
||||
// Domain or subdomain, single and multiple values
|
||||
`(string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com")`,
|
||||
`((string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com") || (string(tls?.req?.sni) == "evil.test" || string(tls?.req?.sni) endsWith ".evil.test")) && proto == "tcp"`,
|
||||
// CIDR and GeoIP, including negation
|
||||
`(cidr(ip.dst, "10.0.0.0/8") || cidr(ip.dst, "fd00::/8")) || !(geoip(ip.dst, "cn") || geoip(ip.dst, "hk"))`,
|
||||
// Port equality and ranges
|
||||
`(port.dst >= 1000 && port.dst <= 2000) && (port.src == 80 || port.src == 443)`,
|
||||
// Wildcards over DNS questions
|
||||
`any(dns?.questions ?? [], {(string(.name) endsWith ".ads.com" || string(.name) startsWith "x.")})`,
|
||||
// GeoSite over DNS questions
|
||||
`any(dns?.questions ?? [], {geosite(string(.name), "category-ads-all")})`,
|
||||
// HTTP fields: negation, regular expressions and header lookups
|
||||
`!(string(http?.req?.headers?.host) contains "tracker") && string(http?.req?.path) matches "^/api/v\\d+/" && string(get(http?.req?.headers, "user-agent")) startsWith "curl"`,
|
||||
// Protocol detection
|
||||
`(ssh != nil || trojan != nil)`,
|
||||
// Wildcard edge cases: "any value" and a star in the middle
|
||||
`string(quic?.req?.sni) != "" && string(tls?.req?.sni) matches "^www\\..*\\.com$" && ip.src == "1.2.3.4" && string(http?.req?.method) == "POST"`,
|
||||
}
|
||||
|
||||
func TestCompileBuilderExpressions(t *testing.T) {
|
||||
analyzers := []analyzer.Analyzer{
|
||||
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
|
||||
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
|
||||
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
|
||||
}
|
||||
modifiers := []modifier.Modifier{&modUDP.DNSModifier{}}
|
||||
config := &BuiltinConfig{
|
||||
Logger: nopLogger{},
|
||||
// Point at a file that does not exist: expressions still have to
|
||||
// compile, only loading the database is expected to fail.
|
||||
GeoSiteFilename: "testdata/missing-geosite.dat",
|
||||
GeoIpFilename: "testdata/missing-geoip.dat",
|
||||
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||
},
|
||||
}
|
||||
|
||||
for _, expr := range builderExpressions {
|
||||
rules := []ExprRule{{Name: "test", Action: "block", Expr: expr}}
|
||||
_, err := CompileExprRules(rules, analyzers, modifiers, config)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
// geoip()/geosite() need a database, which this test does not ship.
|
||||
// Reaching the initialization step means the expression itself is fine.
|
||||
if strings.Contains(err.Error(), "failed to initialize function") {
|
||||
continue
|
||||
}
|
||||
t.Errorf("expression failed to compile: %s\n %v", expr, err)
|
||||
}
|
||||
}
|
||||
|
||||
type nopLogger struct{}
|
||||
|
||||
func (nopLogger) Log(StreamInfo, string) {}
|
||||
func (nopLogger) MatchError(StreamInfo, string, error) {}
|
||||
@@ -1,6 +1,7 @@
|
||||
package ruleset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
|
||||
@@ -100,7 +101,8 @@ type Logger interface {
|
||||
}
|
||||
|
||||
type BuiltinConfig struct {
|
||||
Logger Logger
|
||||
GeoSiteFilename string
|
||||
GeoIpFilename string
|
||||
Logger Logger
|
||||
GeoSiteFilename string
|
||||
GeoIpFilename string
|
||||
ProtectedDialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||
}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
# OpenGFW Web UI
|
||||
|
||||
A Vue 3 + Tailwind CSS dashboard for OpenGFW, built with [shadcn/ui](https://ui.shadcn.com)
|
||||
style components on top of [Reka UI](https://reka-ui.com) primitives. It is embedded into
|
||||
the OpenGFW binary and served by the `web` package.
|
||||
|
||||
- **Dashboard** — live counters, traffic chart, verdict/protocol split, top hosts,
|
||||
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** — 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
|
||||
|
||||
The layout is responsive: a sidebar on desktop, a bottom tab bar and bottom-sheet
|
||||
dialogs on phones.
|
||||
|
||||
## Enabling it
|
||||
|
||||
```yaml
|
||||
# config.yaml
|
||||
web:
|
||||
enabled: true
|
||||
listen: :8080
|
||||
secret: your-password-here
|
||||
# cert: /path/to/fullchain.pem
|
||||
# key: /path/to/privkey.pem
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
web/
|
||||
├── api.go HTTP handlers (JSON API + SSE)
|
||||
├── auth.go password login, session tokens
|
||||
├── hub.go statistics collection and the live event fan-out
|
||||
├── server.go routes, static file serving, public types
|
||||
├── embed.go //go:embed of dist
|
||||
├── devserver/ standalone server with synthetic data (any OS)
|
||||
├── dist/ built UI, embedded into the binary (committed)
|
||||
└── frontend/ Vue sources
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The engine only builds on Linux, so for UI work there is a standalone server that
|
||||
feeds the UI synthetic traffic and an in-memory ruleset:
|
||||
|
||||
```bash
|
||||
go run ./web/devserver # http://127.0.0.1:8080, password: opengfw
|
||||
```
|
||||
|
||||
Then, in another terminal, run Vite with hot reload (it proxies `/api` to `:8080`):
|
||||
|
||||
```bash
|
||||
cd web/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To produce the embedded build (this is what `make web` runs):
|
||||
|
||||
```bash
|
||||
cd web/frontend && npm run build # writes ../dist
|
||||
```
|
||||
|
||||
`web/dist` is committed so that `go build` works without Node installed. Rebuild it
|
||||
whenever you change the frontend.
|
||||
|
||||
## API
|
||||
|
||||
All endpoints live under `/api/v1` and return JSON. Except for `login`, every request
|
||||
must carry `Authorization: Bearer <token>`; `GET` endpoints also accept the session
|
||||
cookie set at login, which is what the `EventSource` connection uses. Mutating
|
||||
endpoints only accept the bearer token, which makes them immune to CSRF.
|
||||
|
||||
| Method | Path | Description |
|
||||
| ---------- | -------------------- | -------------------------------------------------- |
|
||||
| `POST` | `/login` | exchange the password for a session token |
|
||||
| `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 |
|
||||
| `GET/PUT` | `/rules` | read / replace the ruleset (`raw` YAML or `rules`) |
|
||||
| `POST` | `/rules/validate` | compile without applying; also converts YAML ⇄ rules |
|
||||
+317
@@ -0,0 +1,317 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type apiError struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, apiError{Error: msg})
|
||||
}
|
||||
|
||||
func methodAllowed(w http.ResponseWriter, r *http.Request, methods ...string) bool {
|
||||
for _, m := range methods {
|
||||
if r.Method == m {
|
||||
return true
|
||||
}
|
||||
}
|
||||
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||
return false
|
||||
}
|
||||
|
||||
func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB is plenty for a rule file
|
||||
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// POST /api/v1/login
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
token, expiry, err := s.auth.login(r.RemoteAddr, req.Password)
|
||||
if err != nil {
|
||||
code := http.StatusUnauthorized
|
||||
if errors.Is(err, errTooManyAttempts) {
|
||||
code = http.StatusTooManyRequests
|
||||
}
|
||||
writeError(w, code, err.Error())
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
Expires: expiry,
|
||||
HttpOnly: true,
|
||||
Secure: s.TLS(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"token": token,
|
||||
"expiresAt": expiry.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/logout
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
s.auth.logout(bearerToken(r))
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: sessionCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: s.TLS(),
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// GET /api/v1/info
|
||||
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
var info Info
|
||||
if s.config.Info != nil {
|
||||
info = s.config.Info()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, info)
|
||||
}
|
||||
|
||||
// GET /api/v1/meta
|
||||
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.config.Meta)
|
||||
}
|
||||
|
||||
// GET /api/v1/metrics
|
||||
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.config.Hub.Metrics())
|
||||
}
|
||||
|
||||
// GET /api/v1/events?limit=200
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
limit := 200
|
||||
if v := r.URL.Query().Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"events": s.config.Hub.Events(limit),
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/v1/live - server-sent events carrying live events and metrics.
|
||||
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet) {
|
||||
return
|
||||
}
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||
return
|
||||
}
|
||||
h := w.Header()
|
||||
h.Set("Content-Type", "text/event-stream")
|
||||
h.Set("Cache-Control", "no-cache")
|
||||
h.Set("Connection", "keep-alive")
|
||||
h.Set("X-Accel-Buffering", "no")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
flusher.Flush()
|
||||
|
||||
events, unsubscribe := s.config.Hub.Subscribe()
|
||||
defer unsubscribe()
|
||||
|
||||
metricsTicker := time.NewTicker(2 * time.Second)
|
||||
defer metricsTicker.Stop()
|
||||
keepAlive := time.NewTicker(20 * time.Second)
|
||||
defer keepAlive.Stop()
|
||||
|
||||
send := func(event string, v interface{}) bool {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
|
||||
return false
|
||||
}
|
||||
flusher.Flush()
|
||||
return true
|
||||
}
|
||||
|
||||
if !send("metrics", s.config.Hub.Metrics()) {
|
||||
return
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
case ev, ok := <-events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !send("event", ev) {
|
||||
return
|
||||
}
|
||||
case <-metricsTicker.C:
|
||||
if !send("metrics", s.config.Hub.Metrics()) {
|
||||
return
|
||||
}
|
||||
case <-keepAlive.C:
|
||||
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
|
||||
return
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
type rulesResponse struct {
|
||||
Path string `json:"path"`
|
||||
Raw string `json:"raw"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
// resolveRaw turns a request into rule file content.
|
||||
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
|
||||
if req.Rules != nil {
|
||||
return s.config.Rules.Marshal(req.Rules)
|
||||
}
|
||||
return req.Raw, nil
|
||||
}
|
||||
|
||||
// GET/PUT /api/v1/rules
|
||||
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
|
||||
return
|
||||
}
|
||||
if s.config.Rules == nil {
|
||||
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
raw, rules, err := s.config.Rules.Load()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||
return
|
||||
}
|
||||
var req rulesRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
raw, err := s.resolveRaw(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rules, err := s.config.Rules.Apply(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
|
||||
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||
}
|
||||
|
||||
// POST /api/v1/rules/validate
|
||||
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if s.config.Rules == nil {
|
||||
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||
return
|
||||
}
|
||||
var req rulesRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
raw, err := s.resolveRaw(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
rules, err := s.config.Rules.Validate(raw)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"valid": false,
|
||||
"error": err.Error(),
|
||||
"raw": raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"valid": true,
|
||||
"rules": rules,
|
||||
"raw": raw,
|
||||
})
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "opengfw_session"
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
maxLoginFailures = 8
|
||||
loginBanDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
type authenticator struct {
|
||||
secret string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time // token -> expiry
|
||||
failures map[string]*failureRecord
|
||||
}
|
||||
|
||||
type failureRecord struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newAuthenticator(secret string) *authenticator {
|
||||
return &authenticator{
|
||||
secret: secret,
|
||||
sessions: make(map[string]time.Time),
|
||||
failures: make(map[string]*failureRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// RandomSecret generates a secret to be used when the user did not set one.
|
||||
func RandomSecret() string {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "opengfw"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// login verifies the password and returns a new session token.
|
||||
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
|
||||
ip := hostOnly(remoteAddr)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
|
||||
return "", time.Time{}, errTooManyAttempts
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
|
||||
rec, ok := a.failures[ip]
|
||||
if !ok || now.After(rec.until) {
|
||||
rec = &failureRecord{}
|
||||
a.failures[ip] = rec
|
||||
}
|
||||
rec.count++
|
||||
rec.until = now.Add(loginBanDuration)
|
||||
return "", time.Time{}, errBadCredentials
|
||||
}
|
||||
delete(a.failures, ip)
|
||||
token := newToken()
|
||||
if token == "" {
|
||||
return "", time.Time{}, errInternal
|
||||
}
|
||||
expiry := now.Add(sessionTTL)
|
||||
a.sessions[token] = expiry
|
||||
a.gcLocked(now)
|
||||
return token, expiry, nil
|
||||
}
|
||||
|
||||
func (a *authenticator) logout(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *authenticator) valid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
expiry, ok := a.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiry) {
|
||||
delete(a.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *authenticator) gcLocked(now time.Time) {
|
||||
for t, exp := range a.sessions {
|
||||
if now.After(exp) {
|
||||
delete(a.sessions, t)
|
||||
}
|
||||
}
|
||||
for ip, rec := range a.failures {
|
||||
if now.After(rec.until) {
|
||||
delete(a.failures, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken returns the token carried by the Authorization header, if any.
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
|
||||
return h[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookieToken returns the token carried by the session cookie, if any.
|
||||
func cookieToken(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
// Command devserver runs the OpenGFW web UI against synthetic data.
|
||||
//
|
||||
// The engine itself only builds on Linux (it needs NFQueue), so this little
|
||||
// program exists to let the frontend be developed and reviewed anywhere:
|
||||
//
|
||||
// go run ./web/devserver
|
||||
//
|
||||
// It serves the embedded UI on :8080 with the password "opengfw".
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
const password = "opengfw"
|
||||
|
||||
func main() {
|
||||
hub := web.NewHub()
|
||||
rm := &memoryRules{}
|
||||
if err := rm.init(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
srv, err := web.NewServer(web.Config{
|
||||
Listen: ":8080",
|
||||
Secret: password,
|
||||
Hub: hub,
|
||||
Rules: rm,
|
||||
Meta: web.MetaInfo{
|
||||
Analyzers: []web.AnalyzerInfo{
|
||||
{Name: "http", Proto: "tcp"}, {Name: "tls", Proto: "tcp"},
|
||||
{Name: "ssh", Proto: "tcp"}, {Name: "socks", Proto: "tcp"},
|
||||
{Name: "trojan", Proto: "tcp"}, {Name: "fet", Proto: "tcp"},
|
||||
{Name: "dns", Proto: "udp"}, {Name: "quic", Proto: "udp"},
|
||||
{Name: "openvpn", Proto: "udp"}, {Name: "wireguard", Proto: "udp"},
|
||||
},
|
||||
Modifiers: []string{"dns"},
|
||||
Actions: []string{"allow", "block", "drop", "modify"},
|
||||
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||
},
|
||||
Info: func() web.Info {
|
||||
host, _ := os.Hostname()
|
||||
return web.Info{
|
||||
Version: "devserver",
|
||||
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||
GoVersion: runtime.Version(),
|
||||
Hostname: host,
|
||||
RuleFile: "rules.yaml (in memory)",
|
||||
Config: web.ConfigDigest{
|
||||
IOQueueSize: 1024, IORST: true, Workers: 4,
|
||||
WorkerQueue: 64, UDPMaxStreams: 4096,
|
||||
GeoIP: "geoip.dat", GeoSite: "geosite.dat",
|
||||
},
|
||||
}
|
||||
},
|
||||
Geo: geoData,
|
||||
Logf: log.Printf,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go func() {
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
<-ch
|
||||
cancel()
|
||||
}()
|
||||
|
||||
for i := 0; i < 4; i++ {
|
||||
hub.WorkerStarted()
|
||||
}
|
||||
go generate(ctx, hub)
|
||||
|
||||
log.Printf("web UI on http://127.0.0.1:8080 (password: %s)", password)
|
||||
if err := srv.Run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
hosts = []string{
|
||||
"www.google.com", "github.com", "cdn.jsdelivr.net", "telegram.org",
|
||||
"ads.example.net", "tracker.evil.test", "api.openai.com", "www.wikipedia.org",
|
||||
"registry.npmjs.org", "malware.bad.test",
|
||||
}
|
||||
ips = []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "104.16.132.229", "2606:4700::6810:84e5"}
|
||||
)
|
||||
|
||||
// generate feeds the hub with plausible looking traffic.
|
||||
func generate(ctx context.Context, hub *web.Hub) {
|
||||
rng := rand.New(rand.NewSource(42))
|
||||
ticker := time.NewTicker(120 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
var id int64
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
for n := rng.Intn(6); n >= 0; n-- {
|
||||
id++
|
||||
udp := rng.Intn(3) == 0
|
||||
proto := "tcp"
|
||||
if udp {
|
||||
proto = "udp"
|
||||
}
|
||||
hub.StreamNew(proto)
|
||||
|
||||
host := hosts[rng.Intn(len(hosts))]
|
||||
props := web.Props{}
|
||||
if udp {
|
||||
props["dns"] = web.PropMap{
|
||||
"qr": false,
|
||||
"questions": []map[string]interface{}{{"name": host, "type": 1}},
|
||||
}
|
||||
} else {
|
||||
props["tls"] = web.PropMap{"req": map[string]interface{}{
|
||||
"sni": host, "version": 771,
|
||||
}}
|
||||
}
|
||||
hub.PropUpdate(props)
|
||||
|
||||
info := web.StreamInfo{
|
||||
ID: id,
|
||||
Proto: proto,
|
||||
SrcIP: fmt.Sprintf("192.168.1.%d", 2+rng.Intn(60)),
|
||||
SrcPort: uint16(20000 + rng.Intn(40000)),
|
||||
DstIP: ips[rng.Intn(len(ips))],
|
||||
DstPort: 443,
|
||||
Props: props,
|
||||
}
|
||||
|
||||
switch {
|
||||
case rng.Intn(10) == 0:
|
||||
hub.RuleLog(info, "log-suspicious")
|
||||
hub.StreamAction(info, "block")
|
||||
case rng.Intn(12) == 0:
|
||||
hub.StreamAction(info, "drop")
|
||||
case rng.Intn(14) == 0:
|
||||
hub.StreamAction(info, "modify")
|
||||
case rng.Intn(30) == 0:
|
||||
hub.Error(info, "geoip-rule", "lookup timeout")
|
||||
default:
|
||||
hub.StreamAction(info, "allow")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
const seedRules = `- name: block-malware
|
||||
action: block
|
||||
log: true
|
||||
expr: 'tls != nil && tls.req != nil && string(tls.req.sni) endsWith ".bad.test"'
|
||||
- name: block-ads-dns
|
||||
action: drop
|
||||
expr: 'dns != nil && any(dns.questions, {.name endsWith "ads.example.net"})'
|
||||
- name: log-ssh
|
||||
log: true
|
||||
expr: 'ssh != nil'
|
||||
`
|
||||
|
||||
func (m *memoryRules) init() error {
|
||||
rules, err := parse(seedRules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.raw, m.rules = seedRules, rules
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memoryRules) Path() string { return "rules.yaml" }
|
||||
|
||||
func (m *memoryRules) Load() (string, []web.Rule, error) { return m.raw, m.rules, nil }
|
||||
|
||||
func (m *memoryRules) Validate(raw string) ([]web.Rule, error) { return parse(raw) }
|
||||
|
||||
func (m *memoryRules) Marshal(rules []web.Rule) (string, error) {
|
||||
bs, err := yaml.Marshal(rules)
|
||||
return string(bs), err
|
||||
}
|
||||
|
||||
func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
|
||||
rules, err := parse(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.raw, m.rules = raw, rules
|
||||
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) {
|
||||
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
|
||||
}
|
||||
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}
|
||||
}
|
||||
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})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.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-DIqASLVQ.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-B_11D5UP.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.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const D=M("BoxesIcon",[["path",{d:"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z",key:"lc1i9w"}],["path",{d:"m7 16.5-4.74-2.85",key:"1o9zyk"}],["path",{d:"m7 16.5 5-3",key:"va8pkn"}],["path",{d:"M7 16.5v5.17",key:"jnp8gn"}],["path",{d:"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z",key:"8zsnat"}],["path",{d:"m17 16.5-5-3",key:"8arw3v"}],["path",{d:"m17 16.5 4.74-2.85",key:"8rfmw"}],["path",{d:"M17 16.5v5.17",key:"k6z78m"}],["path",{d:"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z",key:"1xygjf"}],["path",{d:"M12 8 7.26 5.15",key:"1vbdud"}],["path",{d:"m12 8 4.74-2.85",key:"3rx089"}],["path",{d:"M12 13.5V8",key:"1io7kd"}]]);/**
|
||||
* @license lucide-vue-next v0.469.0 - ISC
|
||||
*
|
||||
* 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"},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};
|
||||
@@ -0,0 +1 @@
|
||||
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-DIqASLVQ.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 _};
|
||||
@@ -0,0 +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-DIqASLVQ.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{aB as h,aC as x,m as v,g as p,aD as R,al as S,d as g,aj as b,ap as w,aE as y,x as P,aF as k}from"./index-DIqASLVQ.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};
|
||||
+26
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.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-DIqASLVQ.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.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const w=h("ArrowRightIcon",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]),y=x({__name:"ActionBadge",props:{event:{}},setup(l){const e=l,u=v(()=>{if(e.event.kind==="error")return"block";if(e.event.kind==="log")return"log";switch(e.event.action){case"allow":return"allow";case"block":return"block";case"drop":return"drop";case"modify":return"modify";default:return"muted"}}),s=v(()=>e.event.kind==="error"?d("action.error"):e.event.kind==="log"?d("action.log"):d(`action.${e.event.action??"maybe"}`));return(g,N)=>(o(),m(t(a),{variant:u.value,class:"uppercase"},{default:c(()=>[i(n(s.value),1)]),_:1},8,["variant"]))}}),B={class:"flex items-center gap-2"},I={class:"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums"},$={class:"flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs"},A={class:"text-muted-foreground truncate"},C={class:"truncate"},E=x({__name:"EventItem",props:{event:{}},emits:["select"],setup(l){const e=l;return(u,s)=>(o(),k("button",{type:"button",class:"hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3",onClick:s[0]||(s[0]=g=>u.$emit("select",e.event))},[r("div",B,[r("span",I,n(t(b)(e.event.time)),1),p(y,{event:e.event},null,8,["event"]),p(t(a),{variant:e.event.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:c(()=>[i(n(e.event.proto),1)]),_:1},8,["variant"]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"max-w-32 truncate sm:hidden"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]),r("div",$,[r("span",A,n(t(_)(e.event.srcIP,e.event.srcPort)),1),p(t(w),{class:"text-muted-foreground/60 size-3 shrink-0"}),r("span",C,n(e.event.host||t(_)(e.event.dstIP,e.event.dstPort)),1)]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"hidden max-w-40 truncate sm:inline-flex"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]))}});export{w as A,E as _,y as a};
|
||||
+16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +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-DIqASLVQ.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 _};
|
||||
+46
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
@@ -0,0 +1,16 @@
|
||||
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-DIqASLVQ.js";import{_ as J}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as x,a as h,c as b,b as K}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-CzaVl8rJ.js";import"./Collection-CwtwoTtI.js";/**
|
||||
* @license lucide-vue-next v0.469.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const W=y("BookOpenIcon",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/**
|
||||
* @license lucide-vue-next v0.469.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const X=y("LogOutIcon",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/**
|
||||
* @license lucide-vue-next v0.469.0 - ISC
|
||||
*
|
||||
* 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=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};
|
||||
@@ -0,0 +1 @@
|
||||
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-DIqASLVQ.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
Vendored
+1
@@ -0,0 +1 @@
|
||||
function o(t){if(!Number.isFinite(t))return"0";if(t<1e3)return String(t);const e=["k","M","G","T"];let n=t,r=-1;for(;n>=1e3&&r<e.length-1;)n/=1e3,r++;return`${n>=100?Math.round(n):n.toFixed(1).replace(/\.0$/,"")}${e[r]}`}function i(t,e){return e?Math.round(t/e*100):0}function u(t){if(t<60)return`${Math.max(0,Math.floor(t))}s`;const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`}function a(t){return new Date(t).toLocaleTimeString(void 0,{hour12:!1})}function f(t){const e=new Date(t);return`${e.toLocaleDateString()} ${e.toLocaleTimeString(void 0,{hour12:!1})}`}function c(t,e){return t.includes(":")?`[${t}]:${e}`:`${t}:${e}`}export{f as a,o as c,u as d,c as e,i as p,a as t};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+106
File diff suppressed because one or more lines are too long
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||
<path
|
||||
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||
fill="none"
|
||||
stroke="#a1a1aa"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||
/>
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>OpenGFW</title>
|
||||
<script>
|
||||
// Apply the stored theme before first paint to avoid a flash.
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-DIqASLVQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DDyrZ70V.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// distFS holds the compiled web UI. Run `make web` (or `npm run build` inside
|
||||
// web/frontend) to refresh it; the checked-in placeholder simply tells the user
|
||||
// that the UI has not been built yet.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||
/>
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>OpenGFW</title>
|
||||
<script>
|
||||
// Apply the stored theme before first paint to avoid a flash.
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "opengfw-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Web UI for OpenGFW",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.10.5",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||
<path
|
||||
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||
fill="none"
|
||||
stroke="#a1a1aa"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import Toaster from '@/components/Toaster.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster />
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
/* Verdict colors, shared by badges, charts and the live feed. */
|
||||
--allow: oklch(0.63 0.14 155);
|
||||
--block: oklch(0.58 0.22 27);
|
||||
--drop: oklch(0.72 0.16 70);
|
||||
--modify: oklch(0.6 0.16 255);
|
||||
--log: oklch(0.62 0.11 215);
|
||||
--tcp: oklch(0.62 0.15 265);
|
||||
--udp: oklch(0.68 0.13 190);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.19 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--allow: oklch(0.72 0.16 155);
|
||||
--block: oklch(0.68 0.2 22);
|
||||
--drop: oklch(0.79 0.15 75);
|
||||
--modify: oklch(0.7 0.15 255);
|
||||
--log: oklch(0.72 0.11 215);
|
||||
--tcp: oklch(0.7 0.15 265);
|
||||
--udp: oklch(0.76 0.12 190);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-allow: var(--allow);
|
||||
--color-block: var(--block);
|
||||
--color-drop: var(--drop);
|
||||
--color-modify: var(--modify);
|
||||
--color-log: var(--log);
|
||||
--color-tcp: var(--tcp);
|
||||
--color-udp: var(--udp);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
|
||||
--animate-fade-in: fade-in 0.18s ease-out;
|
||||
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
@utility safe-bottom {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
||||
}
|
||||
|
||||
@utility safe-top {
|
||||
padding-top: max(env(safe-area-inset-top), 0px);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import type { LiveEvent } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ event: LiveEvent }>()
|
||||
|
||||
const variant = computed(() => {
|
||||
if (props.event.kind === 'error') return 'block'
|
||||
if (props.event.kind === 'log') return 'log'
|
||||
switch (props.event.action) {
|
||||
case 'allow':
|
||||
return 'allow'
|
||||
case 'block':
|
||||
return 'block'
|
||||
case 'drop':
|
||||
return 'drop'
|
||||
case 'modify':
|
||||
return 'modify'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
})
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.event.kind === 'error') return t('action.error')
|
||||
if (props.event.kind === 'log') return t('action.log')
|
||||
return t(`action.${props.event.action ?? 'maybe'}`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import StatusPill from '@/components/StatusPill.vue'
|
||||
import { startLive, stopLive } from '@/composables/useLive'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const route = useRoute()
|
||||
useTheme()
|
||||
|
||||
const items = computed(() => [
|
||||
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
||||
{ to: '/events', label: t('nav.events'), icon: Radio },
|
||||
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
|
||||
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
|
||||
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
|
||||
])
|
||||
|
||||
const title = computed(() => {
|
||||
const item = items.value.find((i) => i.to === route.path)
|
||||
return item?.label ?? t('app.name')
|
||||
})
|
||||
|
||||
onMounted(startLive)
|
||||
onUnmounted(stopLive)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background min-h-dvh">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside
|
||||
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-2 pb-4">
|
||||
<div
|
||||
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
|
||||
>
|
||||
G
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
|
||||
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-1 flex-col gap-0.5">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
route.path === item.to
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||
)
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" class="size-4 shrink-0" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="px-2 pt-3">
|
||||
<StatusPill />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
|
||||
<!-- Mobile top bar -->
|
||||
<header
|
||||
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between gap-3 px-4">
|
||||
<span class="truncate text-sm font-semibold">{{ title }}</span>
|
||||
<StatusPill />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="animate-fade-in" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom navigation -->
|
||||
<nav
|
||||
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div class="grid grid-cols-5">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
|
||||
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
|
||||
)
|
||||
"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="cn('size-5', route.path === item.to && 'text-primary')"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { compact } from '@/lib/format'
|
||||
import type { NameCount } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
|
||||
{ tone: 'primary' },
|
||||
)
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
|
||||
|
||||
const bars: Record<string, string> = {
|
||||
primary: 'bg-primary/15',
|
||||
block: 'bg-block/15',
|
||||
allow: 'bg-allow/15',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.items.length" class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="item.name"
|
||||
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
|
||||
>
|
||||
<div
|
||||
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
|
||||
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
|
||||
/>
|
||||
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
|
||||
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
|
||||
compact(item.count)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground py-6 text-center text-xs">
|
||||
{{ props.empty ?? '—' }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
import ActionBadge from '@/components/ActionBadge.vue'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { endpoint, time } from '@/lib/format'
|
||||
import type { LiveEvent } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ event: LiveEvent }>()
|
||||
defineEmits<{ select: [event: LiveEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
|
||||
@click="$emit('select', props.event)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
|
||||
{{ time(props.event.time) }}
|
||||
</span>
|
||||
<ActionBadge :event="props.event" />
|
||||
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
|
||||
{{ props.event.proto }}
|
||||
</Badge>
|
||||
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
|
||||
{{ props.event.rule }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
|
||||
<span class="text-muted-foreground truncate">
|
||||
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
|
||||
</span>
|
||||
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
|
||||
{{ props.event.rule }}
|
||||
</Badge>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ title: string; description?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
|
||||
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Card } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
icon?: Component
|
||||
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
|
||||
}>()
|
||||
|
||||
const tones: Record<string, string> = {
|
||||
default: 'text-muted-foreground',
|
||||
allow: 'text-allow',
|
||||
block: 'text-block',
|
||||
drop: 'text-drop',
|
||||
modify: 'text-modify',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-3.5 sm:p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
|
||||
<component
|
||||
:is="props.icon"
|
||||
v-if="props.icon"
|
||||
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
|
||||
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
|
||||
)
|
||||
"
|
||||
>{{ props.value }}</span
|
||||
>
|
||||
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
|
||||
props.hint
|
||||
}}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useLive } from '@/composables/useLive'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const { status } = useLive()
|
||||
|
||||
const label = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'open':
|
||||
return t('common.live')
|
||||
case 'connecting':
|
||||
return t('common.connecting')
|
||||
default:
|
||||
return t('common.offline')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
status === 'open'
|
||||
? 'border-allow/30 bg-allow/10 text-allow'
|
||||
: status === 'connecting'
|
||||
? 'border-drop/30 bg-drop/10 text-drop'
|
||||
: 'border-block/30 bg-block/10 text-block',
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'open'
|
||||
? 'bg-allow animate-pulse-dot'
|
||||
: status === 'connecting'
|
||||
? 'bg-drop animate-pulse-dot'
|
||||
: 'bg-block',
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
|
||||
import { dismiss, useToast } from '@/composables/useToast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const { toasts } = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
|
||||
>
|
||||
<TransitionGroup
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
|
||||
leave-active-class="transition duration-150 ease-in absolute"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
|
||||
item.variant === 'error' && 'border-block/40',
|
||||
item.variant === 'success' && 'border-allow/40',
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
|
||||
<TriangleAlert
|
||||
v-else-if="item.variant === 'error'"
|
||||
class="text-block mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{{ item.title }}</p>
|
||||
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground -m-1 p-1"
|
||||
@click="dismiss(item.id)"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { compact, time } from '@/lib/format'
|
||||
import type { Bucket } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ buckets: Bucket[] }>()
|
||||
|
||||
const W = 600
|
||||
const H = 160
|
||||
const PAD_TOP = 8
|
||||
const PAD_BOTTOM = 16
|
||||
|
||||
const hover = ref<number | null>(null)
|
||||
|
||||
const points = computed(() => props.buckets ?? [])
|
||||
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
|
||||
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
|
||||
|
||||
function x(i: number): number {
|
||||
const n = points.value.length
|
||||
return n <= 1 ? 0 : (i / (n - 1)) * W
|
||||
}
|
||||
|
||||
function y(value: number): number {
|
||||
const usable = H - PAD_TOP - PAD_BOTTOM
|
||||
return PAD_TOP + usable - (value / max.value) * usable
|
||||
}
|
||||
|
||||
/** Builds a closed area path for a series of stacked values. */
|
||||
function area(values: number[]): string {
|
||||
if (values.length === 0) return ''
|
||||
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||
const base = `L${W},${y(0)} L0,${y(0)} Z`
|
||||
return top.join(' ') + ' ' + base
|
||||
}
|
||||
|
||||
function line(values: number[]): string {
|
||||
return values
|
||||
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
|
||||
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
|
||||
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
|
||||
|
||||
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
|
||||
|
||||
function onMove(event: PointerEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = (event.clientX - rect.left) / rect.width
|
||||
const index = Math.round(ratio * (points.value.length - 1))
|
||||
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
|
||||
}
|
||||
|
||||
const legend = computed(() => {
|
||||
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
|
||||
points.value.reduce((acc, b) => acc + b[key], 0)
|
||||
return [
|
||||
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
|
||||
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
|
||||
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="relative touch-pan-y"
|
||||
@pointermove="onMove"
|
||||
@pointerdown="onMove"
|
||||
@pointerleave="hover = null"
|
||||
>
|
||||
<svg
|
||||
:viewBox="`0 0 ${W} ${H}`"
|
||||
preserveAspectRatio="none"
|
||||
class="h-36 w-full sm:h-44"
|
||||
role="img"
|
||||
>
|
||||
<line
|
||||
v-for="frac in [0, 0.5, 1]"
|
||||
:key="frac"
|
||||
:x1="0"
|
||||
:x2="W"
|
||||
:y1="y(max * frac)"
|
||||
:y2="y(max * frac)"
|
||||
class="stroke-border"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"
|
||||
stroke-dasharray="3 4"
|
||||
/>
|
||||
|
||||
<path :d="totalPath" class="fill-udp/25" />
|
||||
<path :d="tcpPath" class="fill-tcp/45" />
|
||||
<path
|
||||
:d="blockedPath"
|
||||
class="stroke-block"
|
||||
fill="none"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
<template v-if="hover !== null">
|
||||
<line
|
||||
:x1="x(hover)"
|
||||
:x2="x(hover)"
|
||||
:y1="PAD_TOP"
|
||||
:y2="y(0)"
|
||||
class="stroke-foreground/40"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
|
||||
>
|
||||
{{ compact(max) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="active"
|
||||
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
|
||||
:style="{
|
||||
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
|
||||
transform:
|
||||
hover! / Math.max(1, points.length - 1) > 0.6
|
||||
? 'translateX(-105%)'
|
||||
: 'translateX(8px)',
|
||||
}"
|
||||
>
|
||||
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
|
||||
<span class="ml-auto font-medium">{{ active.tcp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
|
||||
<span class="ml-auto font-medium">{{ active.udp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
|
||||
<span class="ml-auto font-medium">{{ active.blocked }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasData"
|
||||
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
|
||||
>
|
||||
{{ t('dashboard.noTraffic') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
|
||||
<span :class="['size-2 rounded-full', item.color]" />
|
||||
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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="props.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="props.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="props.condition.negate"
|
||||
@click="patch({ negate: !props.condition.negate })"
|
||||
>
|
||||
{{ t('rules.builder.not') }}
|
||||
</button>
|
||||
<Button
|
||||
v-if="props.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',
|
||||
props.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 props.analyzers"
|
||||
:key="name"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors',
|
||||
props.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="props.condition.values[0] ?? ''"
|
||||
inputmode="numeric"
|
||||
placeholder="1024"
|
||||
class="h-8 w-28 text-xs"
|
||||
@update:model-value="patch({ values: [$event, props.condition.values[1] ?? ''] })"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">–</span>
|
||||
<Input
|
||||
:model-value="props.condition.values[1] ?? ''"
|
||||
inputmode="numeric"
|
||||
placeholder="65535"
|
||||
class="h-8 w-28 text-xs"
|
||||
@update:model-value="patch({ values: [props.condition.values[0] ?? '', $event] })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- GeoIP / GeoSite pickers -->
|
||||
<GeoPicker
|
||||
v-else-if="props.condition.op === 'geoip' || props.condition.op === 'geosite'"
|
||||
:model-value="props.condition.values"
|
||||
:kind="props.condition.op"
|
||||
@update:model-value="patch({ values: $event })"
|
||||
/>
|
||||
|
||||
<!-- Everything else: a list of values -->
|
||||
<template v-else>
|
||||
<TagsInput
|
||||
:model-value="props.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,39 @@
|
||||
<script lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'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' },
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
|
||||
outline:
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8 rounded-md',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PrimitiveProps & {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: string
|
||||
}
|
||||
>(),
|
||||
{ as: 'button' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="props.as"
|
||||
:as-child="props.asChild"
|
||||
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string | number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Label } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; for?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
:for="props.for"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* A dialog that turns into a bottom drawer on small screens, which is much
|
||||
* easier to reach with a thumb than a centered modal.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
|
||||
/>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
|
||||
// Mobile: bottom drawer
|
||||
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
|
||||
// Desktop: centered dialog
|
||||
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
|
||||
/>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
|
||||
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
|
||||
{{ props.description }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { SelectOption } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
:model-value="props.modelValue"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<SelectTrigger
|
||||
:aria-label="props.ariaLabel"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
|
||||
<ChevronDown class="size-4 shrink-0 opacity-50" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
|
||||
>
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 flex items-center">
|
||||
<Check class="size-4" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
|
||||
{ orientation: 'horizontal' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="separator"
|
||||
:class="
|
||||
cn(
|
||||
'bg-border shrink-0',
|
||||
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
:id="props.id"
|
||||
:model-value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<SwitchThumb
|
||||
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
:model-value="props.modelValue"
|
||||
:class="cn('flex flex-col gap-3', props.class)"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; value: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; value: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
:value="props.value"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:size-3.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</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>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||
'placeholder:text-muted-foreground field-sizing-content',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
export { default as Badge, badgeVariants } from './Badge.vue'
|
||||
export { default as Button, buttonVariants } from './Button.vue'
|
||||
export { default as Card } from './Card.vue'
|
||||
export { default as CardContent } from './CardContent.vue'
|
||||
export { default as CardDescription } from './CardDescription.vue'
|
||||
export { default as CardHeader } from './CardHeader.vue'
|
||||
export { default as CardTitle } from './CardTitle.vue'
|
||||
export { default as Input } from './Input.vue'
|
||||
export { default as Label } from './Label.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as Select } from './Select.vue'
|
||||
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'
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
@@ -0,0 +1,35 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { api } from '@/lib/api'
|
||||
import type { GeoData } from '@/lib/types'
|
||||
|
||||
const geo = ref<GeoData | null>(null)
|
||||
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
|
||||
const loading = ref(false)
|
||||
let pending: Promise<void> | null = null
|
||||
|
||||
/** Loads the geo database listing once and shares it across components. */
|
||||
async function load(force = false): Promise<void> {
|
||||
if (geo.value && !force) return
|
||||
if (pending) return pending
|
||||
loading.value = true
|
||||
pending = (async () => {
|
||||
try {
|
||||
const data = await api.geo(force)
|
||||
geo.value = data
|
||||
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
error.value = { ip: message, site: message }
|
||||
geo.value = { ip: [], site: [] }
|
||||
} finally {
|
||||
loading.value = false
|
||||
pending = null
|
||||
}
|
||||
})()
|
||||
return pending
|
||||
}
|
||||
|
||||
export function useGeo() {
|
||||
return { geo, error, loading, load }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { api, auth, liveUrl } from '@/lib/api'
|
||||
import type { LiveEvent, Metrics } from '@/lib/types'
|
||||
|
||||
const MAX_EVENTS = 500
|
||||
|
||||
export type LiveStatus = 'closed' | 'connecting' | 'open'
|
||||
|
||||
const metrics = ref<Metrics | null>(null)
|
||||
const events = shallowRef<LiveEvent[]>([])
|
||||
const status = ref<LiveStatus>('closed')
|
||||
|
||||
let source: EventSource | null = null
|
||||
let retryTimer: number | undefined
|
||||
let retryDelay = 1000
|
||||
|
||||
function pushEvent(event: LiveEvent) {
|
||||
const next = [event, ...events.value]
|
||||
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
|
||||
events.value = next
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
|
||||
metrics.value = m
|
||||
if (events.value.length === 0) events.value = e.events ?? []
|
||||
} catch {
|
||||
// The SSE connection will report the problem, no need to duplicate it.
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (source || !auth.authenticated) return
|
||||
status.value = 'connecting'
|
||||
const es = new EventSource(liveUrl)
|
||||
source = es
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
status.value = 'open'
|
||||
retryDelay = 1000
|
||||
})
|
||||
|
||||
es.addEventListener('metrics', (ev) => {
|
||||
try {
|
||||
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
|
||||
status.value = 'open'
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('event', (ev) => {
|
||||
try {
|
||||
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
es.close()
|
||||
if (source === es) source = null
|
||||
status.value = 'closed'
|
||||
// The session may have expired; a plain fetch tells us for sure.
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (auth.authenticated) connect()
|
||||
}, retryDelay)
|
||||
retryDelay = Math.min(retryDelay * 2, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
export function startLive() {
|
||||
if (!auth.authenticated) return
|
||||
void seed()
|
||||
connect()
|
||||
}
|
||||
|
||||
export function stopLive() {
|
||||
window.clearTimeout(retryTimer)
|
||||
source?.close()
|
||||
source = null
|
||||
status.value = 'closed'
|
||||
metrics.value = null
|
||||
events.value = []
|
||||
}
|
||||
|
||||
export function clearEvents() {
|
||||
events.value = []
|
||||
}
|
||||
|
||||
export function useLive() {
|
||||
return {
|
||||
metrics,
|
||||
events,
|
||||
status,
|
||||
connected: computed(() => status.value === 'open'),
|
||||
startLive,
|
||||
stopLive,
|
||||
clearEvents,
|
||||
refresh: seed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
const THEME_KEY = 'opengfw.theme'
|
||||
const stored = localStorage.getItem(THEME_KEY)
|
||||
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function resolve(value: Theme): boolean {
|
||||
return value === 'dark' || (value === 'system' && media.matches)
|
||||
}
|
||||
|
||||
const isDark = ref(resolve(theme.value))
|
||||
|
||||
function apply() {
|
||||
isDark.value = resolve(theme.value)
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
|
||||
}
|
||||
|
||||
watch(theme, (value) => {
|
||||
localStorage.setItem(THEME_KEY, value)
|
||||
apply()
|
||||
})
|
||||
|
||||
media.addEventListener('change', () => {
|
||||
if (theme.value === 'system') apply()
|
||||
})
|
||||
|
||||
apply()
|
||||
|
||||
export function useTheme() {
|
||||
return {
|
||||
theme,
|
||||
isDark: computed(() => isDark.value),
|
||||
setTheme: (value: Theme) => {
|
||||
theme.value = value
|
||||
},
|
||||
toggle: () => {
|
||||
theme.value = isDark.value ? 'light' : 'dark'
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 1
|
||||
|
||||
function push(title: string, variant: ToastVariant, description?: string) {
|
||||
const id = nextId++
|
||||
toasts.value = [...toasts.value, { id, title, description, variant }]
|
||||
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
info: (title: string, description?: string) => push(title, 'default', description),
|
||||
success: (title: string, description?: string) => push(title, 'success', description),
|
||||
error: (title: string, description?: string) => push(title, 'error', description),
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return { toasts, toast, dismiss }
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
export default {
|
||||
app: {
|
||||
name: 'OpenGFW',
|
||||
tagline: 'Network filtering and analysis',
|
||||
},
|
||||
nav: {
|
||||
dashboard: 'Dashboard',
|
||||
events: 'Events',
|
||||
rules: 'Rules',
|
||||
analyzers: 'Analyzers',
|
||||
settings: 'Settings',
|
||||
},
|
||||
common: {
|
||||
save: 'Save',
|
||||
saving: 'Saving…',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
refresh: 'Refresh',
|
||||
search: 'Search',
|
||||
loading: 'Loading…',
|
||||
empty: 'Nothing here yet',
|
||||
error: 'Error',
|
||||
retry: 'Retry',
|
||||
delete: 'Delete',
|
||||
edit: 'Edit',
|
||||
add: 'Add',
|
||||
apply: 'Apply',
|
||||
copy: 'Copy',
|
||||
copied: 'Copied',
|
||||
enabled: 'Enabled',
|
||||
disabled: 'Disabled',
|
||||
all: 'All',
|
||||
unknown: 'Unknown',
|
||||
total: 'Total',
|
||||
live: 'Live',
|
||||
offline: 'Disconnected',
|
||||
connecting: 'Connecting…',
|
||||
back: 'Back',
|
||||
more: 'More',
|
||||
},
|
||||
login: {
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter the web UI password from your config file',
|
||||
password: 'Password',
|
||||
submit: 'Sign in',
|
||||
failed: 'Sign in failed',
|
||||
hint: 'Set it with web.secret in config.yaml',
|
||||
},
|
||||
dashboard: {
|
||||
title: 'Dashboard',
|
||||
subtitle: 'Live overview of the engine',
|
||||
uptime: 'Uptime',
|
||||
workers: 'Workers',
|
||||
streams: 'Streams',
|
||||
blocked: 'Blocked',
|
||||
allowed: 'Allowed',
|
||||
modified: 'Modified',
|
||||
errors: 'Errors',
|
||||
traffic: 'Traffic',
|
||||
trafficDesc: 'New streams and verdicts over time',
|
||||
range10m: '10 min',
|
||||
range1h: '1 hour',
|
||||
protocols: 'Protocols',
|
||||
verdicts: 'Verdicts',
|
||||
topHosts: 'Top hosts',
|
||||
topHostsDesc: 'Seen in TLS SNI, HTTP Host and DNS queries',
|
||||
topBlocked: 'Blocked destinations',
|
||||
topRules: 'Triggered rules',
|
||||
recent: 'Recent events',
|
||||
viewAll: 'View all',
|
||||
noTraffic: 'No traffic seen yet',
|
||||
tcp: 'TCP',
|
||||
udp: 'UDP',
|
||||
},
|
||||
events: {
|
||||
title: 'Events',
|
||||
subtitle: 'Live feed of verdicts, rule logs and errors',
|
||||
pause: 'Pause',
|
||||
resume: 'Resume',
|
||||
paused: 'Paused',
|
||||
clear: 'Clear',
|
||||
filterAction: 'Verdict',
|
||||
filterProto: 'Protocol',
|
||||
filterKind: 'Type',
|
||||
searchPlaceholder: 'Search IP, host or rule…',
|
||||
empty: 'Waiting for events…',
|
||||
emptyFiltered: 'No event matches the filter',
|
||||
detail: 'Event detail',
|
||||
stream: 'Stream',
|
||||
source: 'Source',
|
||||
destination: 'Destination',
|
||||
host: 'Host',
|
||||
rule: 'Rule',
|
||||
props: 'Analyzer properties',
|
||||
noProps: 'No analyzer properties',
|
||||
copyJson: 'Copy JSON',
|
||||
buffered: '{n} new',
|
||||
},
|
||||
rules: {
|
||||
title: 'Rules',
|
||||
subtitle: 'Rules are matched top to bottom; the first match wins',
|
||||
count: '{n} rules',
|
||||
file: 'Rule file',
|
||||
add: 'New rule',
|
||||
editTitle: 'Edit rule',
|
||||
addTitle: 'New rule',
|
||||
name: 'Name',
|
||||
namePlaceholder: 'block-google-dns',
|
||||
action: 'Action',
|
||||
actionNone: 'None (log only)',
|
||||
log: 'Log matches',
|
||||
expr: 'Expression',
|
||||
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
|
||||
modifier: 'Modifier',
|
||||
modifierArgs: 'Modifier arguments (JSON)',
|
||||
visual: 'Visual',
|
||||
yaml: 'YAML',
|
||||
validate: 'Validate',
|
||||
valid: 'Rules are valid',
|
||||
invalid: 'Invalid rules',
|
||||
save: 'Save & apply',
|
||||
saved: 'Rules applied to the running engine',
|
||||
unsaved: 'Unsaved changes',
|
||||
discard: 'Discard',
|
||||
deleteConfirm: 'Delete rule "{name}"?',
|
||||
moveUp: 'Move up',
|
||||
moveDown: 'Move down',
|
||||
empty: 'No rules yet. Everything is accepted.',
|
||||
nameRequired: 'A name 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',
|
||||
subtitle: 'Protocol analyzers compiled into this build',
|
||||
hits: 'Streams analyzed',
|
||||
modifiers: 'Modifiers',
|
||||
modifiersDesc: 'Available for rules with the modify action',
|
||||
functions: 'Rule functions',
|
||||
functionsDesc: 'Built-in functions usable in expressions',
|
||||
used: 'In use',
|
||||
idle: 'Idle',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
subtitle: 'Appearance and instance information',
|
||||
appearance: 'Appearance',
|
||||
theme: 'Theme',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
themeSystem: 'System',
|
||||
language: 'Language',
|
||||
instance: 'Instance',
|
||||
version: 'Version',
|
||||
platform: 'Platform',
|
||||
goVersion: 'Go version',
|
||||
hostname: 'Hostname',
|
||||
ruleFile: 'Rule file',
|
||||
engineConfig: 'Engine configuration',
|
||||
ioQueueSize: 'NFQueue size',
|
||||
ioLocal: 'Local mode',
|
||||
ioRST: 'Send RST',
|
||||
workers: 'Workers',
|
||||
workerQueueSize: 'Worker queue size',
|
||||
udpMaxStreams: 'Max UDP streams',
|
||||
geoip: 'GeoIP database',
|
||||
geosite: 'GeoSite database',
|
||||
auto: 'Auto',
|
||||
session: 'Session',
|
||||
logout: 'Sign out',
|
||||
docs: 'Documentation',
|
||||
},
|
||||
action: {
|
||||
allow: 'Allow',
|
||||
block: 'Block',
|
||||
drop: 'Drop',
|
||||
modify: 'Modify',
|
||||
maybe: 'Maybe',
|
||||
log: 'Log',
|
||||
error: 'Error',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import en from './en'
|
||||
import zh from './zh'
|
||||
|
||||
export type Lang = 'en' | 'zh'
|
||||
|
||||
const LANG_KEY = 'opengfw.lang'
|
||||
const dictionaries: Record<Lang, Record<string, unknown>> = { en, zh }
|
||||
|
||||
function detect(): Lang {
|
||||
const stored = localStorage.getItem(LANG_KEY)
|
||||
if (stored === 'en' || stored === 'zh') return stored
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
const lang = ref<Lang>(detect())
|
||||
|
||||
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part]
|
||||
return undefined
|
||||
}, dict)
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Translates a dotted key, interpolating {placeholders}. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const raw = lookup(dictionaries[lang.value], key) ?? lookup(dictionaries.en, key) ?? key
|
||||
if (!params) return raw
|
||||
return raw.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
params[name] === undefined ? `{${name}}` : String(params[name]),
|
||||
)
|
||||
}
|
||||
|
||||
export function setLang(value: Lang) {
|
||||
lang.value = value
|
||||
localStorage.setItem(LANG_KEY, value)
|
||||
document.documentElement.lang = value === 'zh' ? 'zh-CN' : 'en'
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return { t, lang: computed(() => lang.value), setLang }
|
||||
}
|
||||
|
||||
document.documentElement.lang = lang.value === 'zh' ? 'zh-CN' : 'en'
|
||||
@@ -0,0 +1,257 @@
|
||||
export default {
|
||||
app: {
|
||||
name: 'OpenGFW',
|
||||
tagline: '网络过滤与分析',
|
||||
},
|
||||
nav: {
|
||||
dashboard: '概览',
|
||||
events: '事件',
|
||||
rules: '规则',
|
||||
analyzers: '解析器',
|
||||
settings: '设置',
|
||||
},
|
||||
common: {
|
||||
save: '保存',
|
||||
saving: '保存中…',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
refresh: '刷新',
|
||||
search: '搜索',
|
||||
loading: '加载中…',
|
||||
empty: '暂无数据',
|
||||
error: '错误',
|
||||
retry: '重试',
|
||||
delete: '删除',
|
||||
edit: '编辑',
|
||||
add: '添加',
|
||||
apply: '应用',
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
enabled: '已启用',
|
||||
disabled: '已禁用',
|
||||
all: '全部',
|
||||
unknown: '未知',
|
||||
total: '总计',
|
||||
live: '实时',
|
||||
offline: '连接断开',
|
||||
connecting: '连接中…',
|
||||
back: '返回',
|
||||
more: '更多',
|
||||
},
|
||||
login: {
|
||||
title: '登录',
|
||||
subtitle: '请输入配置文件中设置的 Web UI 密码',
|
||||
password: '密码',
|
||||
submit: '登录',
|
||||
failed: '登录失败',
|
||||
hint: '在 config.yaml 的 web.secret 中设置',
|
||||
},
|
||||
dashboard: {
|
||||
title: '概览',
|
||||
subtitle: '引擎实时运行状态',
|
||||
uptime: '运行时长',
|
||||
workers: '工作线程',
|
||||
streams: '连接数',
|
||||
blocked: '已拦截',
|
||||
allowed: '已放行',
|
||||
modified: '已修改',
|
||||
errors: '错误',
|
||||
traffic: '流量趋势',
|
||||
trafficDesc: '新建连接与判定结果随时间变化',
|
||||
range10m: '10 分钟',
|
||||
range1h: '1 小时',
|
||||
protocols: '协议分布',
|
||||
verdicts: '判定分布',
|
||||
topHosts: '热门域名',
|
||||
topHostsDesc: '来自 TLS SNI、HTTP Host 与 DNS 查询',
|
||||
topBlocked: '被拦截的目标',
|
||||
topRules: '命中的规则',
|
||||
recent: '最近事件',
|
||||
viewAll: '查看全部',
|
||||
noTraffic: '暂未捕获到流量',
|
||||
tcp: 'TCP',
|
||||
udp: 'UDP',
|
||||
},
|
||||
events: {
|
||||
title: '事件',
|
||||
subtitle: '判定结果、规则日志与错误的实时推送',
|
||||
pause: '暂停',
|
||||
resume: '继续',
|
||||
paused: '已暂停',
|
||||
clear: '清空',
|
||||
filterAction: '判定',
|
||||
filterProto: '协议',
|
||||
filterKind: '类型',
|
||||
searchPlaceholder: '搜索 IP、域名或规则…',
|
||||
empty: '等待事件中…',
|
||||
emptyFiltered: '没有符合筛选条件的事件',
|
||||
detail: '事件详情',
|
||||
stream: '连接',
|
||||
source: '来源',
|
||||
destination: '目标',
|
||||
host: '域名',
|
||||
rule: '规则',
|
||||
props: '解析器属性',
|
||||
noProps: '无解析器属性',
|
||||
copyJson: '复制 JSON',
|
||||
buffered: '{n} 条新事件',
|
||||
},
|
||||
rules: {
|
||||
title: '规则',
|
||||
subtitle: '规则自上而下匹配,命中第一条即生效',
|
||||
count: '{n} 条规则',
|
||||
file: '规则文件',
|
||||
add: '新建规则',
|
||||
editTitle: '编辑规则',
|
||||
addTitle: '新建规则',
|
||||
name: '名称',
|
||||
namePlaceholder: 'block-google-dns',
|
||||
action: '动作',
|
||||
actionNone: '无(仅记录日志)',
|
||||
log: '记录日志',
|
||||
expr: '表达式',
|
||||
exprPlaceholder: 'dns != nil && dns.qr && any(dns.questions, {.name endsWith "example.com"})',
|
||||
modifier: '修改器',
|
||||
modifierArgs: '修改器参数(JSON)',
|
||||
visual: '可视化',
|
||||
yaml: 'YAML',
|
||||
validate: '校验',
|
||||
valid: '规则校验通过',
|
||||
invalid: '规则不合法',
|
||||
save: '保存并应用',
|
||||
saved: '规则已热加载到运行中的引擎',
|
||||
unsaved: '有未保存的修改',
|
||||
discard: '放弃修改',
|
||||
deleteConfirm: '确定删除规则「{name}」?',
|
||||
moveUp: '上移',
|
||||
moveDown: '下移',
|
||||
empty: '暂无规则,所有流量都会放行。',
|
||||
nameRequired: '请填写名称',
|
||||
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: '解析器',
|
||||
subtitle: '当前版本内置的协议解析器',
|
||||
hits: '已解析连接',
|
||||
modifiers: '修改器',
|
||||
modifiersDesc: '可用于 modify 动作的规则',
|
||||
functions: '规则函数',
|
||||
functionsDesc: '表达式中可用的内置函数',
|
||||
used: '使用中',
|
||||
idle: '空闲',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
subtitle: '外观与实例信息',
|
||||
appearance: '外观',
|
||||
theme: '主题',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
themeSystem: '跟随系统',
|
||||
language: '语言',
|
||||
instance: '实例信息',
|
||||
version: '版本',
|
||||
platform: '平台',
|
||||
goVersion: 'Go 版本',
|
||||
hostname: '主机名',
|
||||
ruleFile: '规则文件',
|
||||
engineConfig: '引擎配置',
|
||||
ioQueueSize: 'NFQueue 队列大小',
|
||||
ioLocal: '本机模式',
|
||||
ioRST: '发送 RST',
|
||||
workers: '工作线程数',
|
||||
workerQueueSize: '工作线程队列大小',
|
||||
udpMaxStreams: 'UDP 最大连接数',
|
||||
geoip: 'GeoIP 数据库',
|
||||
geosite: 'GeoSite 数据库',
|
||||
auto: '自动',
|
||||
session: '会话',
|
||||
logout: '退出登录',
|
||||
docs: '文档',
|
||||
},
|
||||
action: {
|
||||
allow: '放行',
|
||||
block: '拦截',
|
||||
drop: '丢弃',
|
||||
modify: '修改',
|
||||
maybe: '待定',
|
||||
log: '日志',
|
||||
error: '错误',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type {
|
||||
GeoData,
|
||||
Info,
|
||||
LiveEvent,
|
||||
MetaInfo,
|
||||
Metrics,
|
||||
Rule,
|
||||
RulesResponse,
|
||||
ValidateResponse,
|
||||
} from './types'
|
||||
|
||||
const BASE = '/api/v1'
|
||||
const TOKEN_KEY = 'opengfw.token'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
let token = localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
const unauthorizedHandlers = new Set<() => void>()
|
||||
|
||||
export const auth = {
|
||||
get token() {
|
||||
return token
|
||||
},
|
||||
get authenticated() {
|
||||
return token !== ''
|
||||
},
|
||||
set(value: string) {
|
||||
token = value
|
||||
localStorage.setItem(TOKEN_KEY, value)
|
||||
},
|
||||
clear() {
|
||||
token = ''
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
},
|
||||
onUnauthorized(handler: () => void) {
|
||||
unauthorizedHandlers.add(handler)
|
||||
return () => unauthorizedHandlers.delete(handler)
|
||||
},
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers)
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(BASE + path, { ...init, headers, credentials: 'same-origin' })
|
||||
} catch {
|
||||
throw new ApiError(0, 'network error')
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
auth.clear()
|
||||
unauthorizedHandlers.forEach((h) => h())
|
||||
throw new ApiError(401, 'unauthorized')
|
||||
}
|
||||
|
||||
const text = await res.text()
|
||||
const data = text ? (JSON.parse(text) as unknown) : null
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
data && typeof data === 'object' && 'error' in data
|
||||
? String((data as { error: unknown }).error)
|
||||
: `request failed (${res.status})`
|
||||
throw new ApiError(res.status, message)
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export const api = {
|
||||
async login(password: string): Promise<void> {
|
||||
const res = await request<{ token: string }>('/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
auth.set(res.token)
|
||||
},
|
||||
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await request('/logout', { method: 'POST' })
|
||||
} finally {
|
||||
auth.clear()
|
||||
}
|
||||
},
|
||||
|
||||
info: () => request<Info>('/info'),
|
||||
meta: () => request<MetaInfo>('/meta'),
|
||||
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) }),
|
||||
|
||||
validateRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
||||
request<ValidateResponse>('/rules/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
}
|
||||
|
||||
export const liveUrl = `${BASE}/live`
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Compact number formatting: 1234 -> 1.2k */
|
||||
export function compact(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
if (n < 1000) return String(n)
|
||||
const units = ['k', 'M', 'G', 'T']
|
||||
let value = n
|
||||
let unit = -1
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000
|
||||
unit++
|
||||
}
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}${units[unit]}`
|
||||
}
|
||||
|
||||
export function percent(part: number, total: number): number {
|
||||
if (!total) return 0
|
||||
return Math.round((part / total) * 100)
|
||||
}
|
||||
|
||||
/** 3725 -> "1h 2m" */
|
||||
export function duration(seconds: number): string {
|
||||
if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export function time(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString(undefined, { hour12: false })
|
||||
}
|
||||
|
||||
export function dateTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${d.toLocaleDateString()} ${d.toLocaleTimeString(undefined, { hour12: false })}`
|
||||
}
|
||||
|
||||
/** Formats an address, wrapping IPv6 in brackets. */
|
||||
export function endpoint(ip: string, port: number): string {
|
||||
return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`
|
||||
}
|
||||
|
||||
export function truncate(s: string, max = 42): string {
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** Helpers for displaying geo database keys. */
|
||||
|
||||
const displayNames = new Map<string, Intl.DisplayNames>()
|
||||
|
||||
function names(lang: string): Intl.DisplayNames | null {
|
||||
const locale = lang === 'zh' ? 'zh-CN' : 'en'
|
||||
if (!displayNames.has(locale)) {
|
||||
try {
|
||||
displayNames.set(locale, new Intl.DisplayNames([locale], { type: 'region' }))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
return displayNames.get(locale) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Localized country name for a two letter code, or null when the code is not a
|
||||
* country. GeoIP databases also contain provider groups such as "cloudflare".
|
||||
*/
|
||||
export function regionName(code: string, lang: string): string | null {
|
||||
if (!/^[a-z]{2}$/i.test(code)) return null
|
||||
const upper = code.toUpperCase()
|
||||
try {
|
||||
const name = names(lang)?.of(upper)
|
||||
return name && name !== upper ? name : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Flag emoji for a two letter country code, empty for anything else. */
|
||||
export function flagOf(code: string): string {
|
||||
if (!/^[a-z]{2}$/i.test(code)) return ''
|
||||
const base = 0x1f1e6
|
||||
const upper = code.toUpperCase()
|
||||
return String.fromCodePoint(
|
||||
base + upper.charCodeAt(0) - 65,
|
||||
base + upper.charCodeAt(1) - 65,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
|
||||
import type { Condition, RuleBuilderState } from './types'
|
||||
|
||||
/** Quotes a value as an expr string literal. */
|
||||
export function quote(value: string): string {
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
|
||||
}
|
||||
|
||||
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
|
||||
|
||||
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
|
||||
export function wildcardToRegex(pattern: string): string {
|
||||
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
|
||||
}
|
||||
|
||||
export interface WildcardExplain {
|
||||
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
|
||||
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
|
||||
value: string
|
||||
}
|
||||
|
||||
/** Describes how a wildcard pattern will be matched, for the inline hint. */
|
||||
export function explainWildcard(pattern: string): WildcardExplain {
|
||||
const p = pattern.trim()
|
||||
if (p === '*') return { kind: 'any', value: '' }
|
||||
if (!p.includes('*')) return { kind: 'exact', value: p }
|
||||
const starts = p.startsWith('*')
|
||||
const ends = p.endsWith('*')
|
||||
const core = p.replace(/^\*/, '').replace(/\*$/, '')
|
||||
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
|
||||
if (starts && ends) return { kind: 'contains', value: core }
|
||||
if (starts) return { kind: 'suffix', value: core }
|
||||
if (ends) return { kind: 'prefix', value: core }
|
||||
return { kind: 'regex', value: wildcardToRegex(p) }
|
||||
}
|
||||
|
||||
function wildcardTerm(subject: string, pattern: string): string {
|
||||
const info = explainWildcard(pattern)
|
||||
switch (info.kind) {
|
||||
case 'any':
|
||||
return `${subject} != ""`
|
||||
case 'exact':
|
||||
return `${subject} == ${quote(info.value)}`
|
||||
case 'suffix':
|
||||
return `${subject} endsWith ${quote(info.value)}`
|
||||
case 'prefix':
|
||||
return `${subject} startsWith ${quote(info.value)}`
|
||||
case 'contains':
|
||||
return `${subject} contains ${quote(info.value)}`
|
||||
case 'regex':
|
||||
return `${subject} matches ${quote(info.value)}`
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds the expression for one value of a condition. */
|
||||
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
|
||||
const v = value.trim()
|
||||
switch (op) {
|
||||
case 'is':
|
||||
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
|
||||
case 'subdomain':
|
||||
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
|
||||
case 'wildcard':
|
||||
return wildcardTerm(subject, v)
|
||||
case 'contains':
|
||||
case 'startsWith':
|
||||
case 'endsWith':
|
||||
return `${subject} ${op} ${quote(v)}`
|
||||
case 'regex':
|
||||
return `${subject} matches ${quote(v)}`
|
||||
case 'geosite':
|
||||
return `geosite(${subject}, ${quote(v.toLowerCase())})`
|
||||
case 'cidr':
|
||||
return `cidr(${subject}, ${quote(v)})`
|
||||
case 'geoip':
|
||||
return `geoip(${subject}, ${quote(v.toLowerCase())})`
|
||||
case 'detected':
|
||||
return `${v} != nil`
|
||||
case 'range':
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
/** Compiles a single condition into an expression, or "" when incomplete. */
|
||||
export function compileCondition(condition: Condition): string {
|
||||
const field = FIELD_MAP[condition.field]
|
||||
if (!field) return ''
|
||||
const values = condition.values.map((v) => v.trim()).filter(Boolean)
|
||||
|
||||
let body: string
|
||||
if (condition.op === 'range') {
|
||||
const [from, to] = condition.values
|
||||
if (!from?.trim() || !to?.trim()) return ''
|
||||
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
|
||||
} else {
|
||||
if (values.length === 0) return ''
|
||||
const subject = field.iter ? (field.item as string) : (field.expr as string)
|
||||
const terms = values.map((v) => term(field, condition.op, subject, v))
|
||||
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
|
||||
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
|
||||
}
|
||||
|
||||
if (!condition.negate) return body
|
||||
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
|
||||
}
|
||||
|
||||
/** Compiles the whole builder state into a rule expression. */
|
||||
export function compileRule(state: RuleBuilderState): string {
|
||||
const parts = state.conditions.map(compileCondition).filter(Boolean)
|
||||
if (parts.length === 0) return ''
|
||||
if (parts.length === 1) return parts[0]
|
||||
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Catalog of the fields and operators the visual rule builder offers.
|
||||
*
|
||||
* Every field maps to a snippet of the expr expression language used by the
|
||||
* engine. Optional chaining (`a?.b`) keeps rules safe when an analyzer did not
|
||||
* produce any property for a stream, and `string(...)` turns a missing value
|
||||
* into an empty string instead of an error.
|
||||
*/
|
||||
|
||||
export type ValueType = 'proto' | 'ip' | 'port' | 'domain' | 'text' | 'analyzer'
|
||||
|
||||
export type OperatorId =
|
||||
| 'is'
|
||||
| 'subdomain'
|
||||
| 'wildcard'
|
||||
| 'contains'
|
||||
| 'startsWith'
|
||||
| 'endsWith'
|
||||
| 'regex'
|
||||
| 'geosite'
|
||||
| 'cidr'
|
||||
| 'geoip'
|
||||
| 'range'
|
||||
| 'detected'
|
||||
|
||||
export interface FieldDef {
|
||||
key: string
|
||||
/** Group used to organise the field picker. */
|
||||
group: 'basic' | 'domain' | 'http' | 'protocol'
|
||||
type: ValueType
|
||||
/** Expression evaluating to the value, already coerced to a string. */
|
||||
expr?: string
|
||||
/** Analyzer this field depends on, shown as a hint in the UI. */
|
||||
analyzer?: string
|
||||
/**
|
||||
* Set for fields that live in an array: `iter` is the list expression and
|
||||
* `item` the expression of a single element inside the predicate.
|
||||
*/
|
||||
iter?: string
|
||||
item?: string
|
||||
}
|
||||
|
||||
export const FIELDS: FieldDef[] = [
|
||||
{ key: 'proto', group: 'basic', type: 'proto', expr: 'proto' },
|
||||
{ key: 'ip.dst', group: 'basic', type: 'ip', expr: 'ip.dst' },
|
||||
{ key: 'ip.src', group: 'basic', type: 'ip', expr: 'ip.src' },
|
||||
{ key: 'port.dst', group: 'basic', type: 'port', expr: 'port.dst' },
|
||||
{ key: 'port.src', group: 'basic', type: 'port', expr: 'port.src' },
|
||||
|
||||
{ key: 'tls.sni', group: 'domain', type: 'domain', expr: 'string(tls?.req?.sni)', analyzer: 'tls' },
|
||||
{
|
||||
key: 'quic.sni',
|
||||
group: 'domain',
|
||||
type: 'domain',
|
||||
expr: 'string(quic?.req?.sni)',
|
||||
analyzer: 'quic',
|
||||
},
|
||||
{
|
||||
key: 'dns.name',
|
||||
group: 'domain',
|
||||
type: 'domain',
|
||||
analyzer: 'dns',
|
||||
iter: 'dns?.questions ?? []',
|
||||
item: 'string(.name)',
|
||||
},
|
||||
|
||||
{
|
||||
key: 'http.host',
|
||||
group: 'http',
|
||||
type: 'domain',
|
||||
expr: 'string(http?.req?.headers?.host)',
|
||||
analyzer: 'http',
|
||||
},
|
||||
{ key: 'http.path', group: 'http', type: 'text', expr: 'string(http?.req?.path)', analyzer: 'http' },
|
||||
{
|
||||
key: 'http.method',
|
||||
group: 'http',
|
||||
type: 'text',
|
||||
expr: 'string(http?.req?.method)',
|
||||
analyzer: 'http',
|
||||
},
|
||||
{
|
||||
key: 'http.ua',
|
||||
group: 'http',
|
||||
type: 'text',
|
||||
expr: 'string(get(http?.req?.headers, "user-agent"))',
|
||||
analyzer: 'http',
|
||||
},
|
||||
|
||||
{ key: 'analyzer', group: 'protocol', type: 'analyzer' },
|
||||
]
|
||||
|
||||
export const FIELD_MAP: Record<string, FieldDef> = Object.fromEntries(
|
||||
FIELDS.map((f) => [f.key, f]),
|
||||
)
|
||||
|
||||
/** Operators offered for each value type, in menu order. */
|
||||
export const OPERATORS: Record<ValueType, OperatorId[]> = {
|
||||
proto: ['is'],
|
||||
ip: ['cidr', 'geoip', 'is'],
|
||||
port: ['is', 'range'],
|
||||
domain: ['subdomain', 'wildcard', 'is', 'contains', 'endsWith', 'startsWith', 'regex', 'geosite'],
|
||||
text: ['contains', 'is', 'startsWith', 'endsWith', 'regex'],
|
||||
analyzer: ['detected'],
|
||||
}
|
||||
|
||||
/** Operators whose value is picked from a list rather than typed. */
|
||||
export const PICKER_OPERATORS: Partial<Record<OperatorId, 'geoip' | 'geosite'>> = {
|
||||
geoip: 'geoip',
|
||||
geosite: 'geosite',
|
||||
}
|
||||
|
||||
export const PROTO_VALUES = ['tcp', 'udp']
|
||||
|
||||
/** `range` takes exactly two values (from and to); everything else is a list. */
|
||||
export function isRangeOperator(op: OperatorId): boolean {
|
||||
return op === 'range'
|
||||
}
|
||||
|
||||
export function defaultOperator(type: ValueType): OperatorId {
|
||||
return OPERATORS[type][0]
|
||||
}
|
||||
|
||||
export function operatorsFor(field: FieldDef): OperatorId[] {
|
||||
return OPERATORS[field.type]
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { FIELDS, type FieldDef, type OperatorId } from './fields'
|
||||
import { newCondition } from './types'
|
||||
import type { Condition, RuleBuilderState } from './types'
|
||||
|
||||
/**
|
||||
* Best-effort conversion of an expression back into builder state.
|
||||
*
|
||||
* It understands the canonical shapes produced by compile.ts, which covers
|
||||
* every rule created through the builder. Hand written expressions that use
|
||||
* anything else simply return null, and the UI falls back to the expression
|
||||
* editor instead of silently rewriting them.
|
||||
*/
|
||||
export function parseRule(expr: string): RuleBuilderState | null {
|
||||
const source = expr.trim()
|
||||
if (!source) return null
|
||||
|
||||
let mode: 'and' | 'or' = 'and'
|
||||
let parts = splitTop(source, '&&')
|
||||
if (parts.length === 1) {
|
||||
const or = splitTop(source, '||')
|
||||
if (or.length > 1) {
|
||||
mode = 'or'
|
||||
parts = or
|
||||
}
|
||||
}
|
||||
|
||||
const conditions: Condition[] = []
|
||||
for (const part of parts) {
|
||||
const condition = parseCondition(part)
|
||||
if (!condition) return null
|
||||
conditions.push(condition)
|
||||
}
|
||||
return { mode, conditions }
|
||||
}
|
||||
|
||||
function parseCondition(input: string): Condition | null {
|
||||
let source = input.trim()
|
||||
let negate = false
|
||||
if (source.startsWith('!')) {
|
||||
negate = true
|
||||
source = source.slice(1).trim()
|
||||
}
|
||||
|
||||
// A single value, including the parenthesised "domain or subdomain" form.
|
||||
const single = parseTerm(source)
|
||||
if (single) {
|
||||
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
|
||||
}
|
||||
|
||||
const body = unwrap(source)
|
||||
|
||||
// Array fields: any(<iter>, {<predicate>})
|
||||
const anyMatch = matchCall(body, 'any')
|
||||
if (anyMatch !== null) {
|
||||
const [iter, closure] = splitTop(anyMatch, ',').map((s) => s.trim())
|
||||
if (!closure?.startsWith('{') || !closure.endsWith('}')) return null
|
||||
const field = FIELDS.find((f) => f.iter === iter)
|
||||
if (!field) return null
|
||||
return finish(field, closure.slice(1, -1).trim(), negate, field.item as string)
|
||||
}
|
||||
|
||||
// Port range: (port.dst >= 1 && port.dst <= 2)
|
||||
const range = splitTop(body, '&&')
|
||||
if (range.length === 2) {
|
||||
const from = /^(\S+)\s*>=\s*(\d+)$/.exec(range[0].trim())
|
||||
const to = /^(\S+)\s*<=\s*(\d+)$/.exec(range[1].trim())
|
||||
if (from && to && from[1] === to[1]) {
|
||||
const field = FIELDS.find((f) => f.expr === from[1] && f.type === 'port')
|
||||
if (!field) return null
|
||||
return newCondition({ field: field.key, op: 'range', negate, values: [from[2], to[2]] })
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (range.length > 1) return null
|
||||
|
||||
return finish(null, body, negate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the values of a condition, which are OR-ed together and must all use
|
||||
* the same field and operator.
|
||||
*/
|
||||
function finish(
|
||||
expected: FieldDef | null,
|
||||
body: string,
|
||||
negate: boolean,
|
||||
subject?: string,
|
||||
): Condition | null {
|
||||
const single = parseTerm(body.trim(), subject)
|
||||
if (single && (!expected || single.field.key === expected.key)) {
|
||||
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
|
||||
}
|
||||
|
||||
const parts = splitTop(unwrap(body), '||')
|
||||
if (parts.length < 2) return null
|
||||
|
||||
const terms: ParsedTerm[] = []
|
||||
let field = expected
|
||||
for (const part of parts) {
|
||||
const parsed = parseTerm(part.trim(), subject)
|
||||
if (!parsed) return null
|
||||
if (!field) field = parsed.field
|
||||
if (parsed.field.key !== field.key) return null
|
||||
terms.push(parsed)
|
||||
}
|
||||
if (!field) return null
|
||||
|
||||
const ops = new Set(terms.map((t) => t.op))
|
||||
if (ops.size === 1) {
|
||||
return newCondition({
|
||||
field: field.key,
|
||||
op: terms[0].op,
|
||||
negate,
|
||||
values: terms.map((t) => t.value),
|
||||
})
|
||||
}
|
||||
|
||||
// Values of a wildcard condition compile to different operators depending on
|
||||
// where the star sits, so fold mixed operators back into wildcard patterns.
|
||||
const patterns = terms.map((t) => toWildcard(t.op, t.value))
|
||||
if (patterns.some((p) => p === null)) return null
|
||||
return newCondition({ field: field.key, op: 'wildcard', negate, values: patterns as string[] })
|
||||
}
|
||||
|
||||
/** Inverse of the wildcard compilation, used when a condition mixes operators. */
|
||||
function toWildcard(op: OperatorId, value: string): string | null {
|
||||
switch (op) {
|
||||
case 'wildcard':
|
||||
return value
|
||||
case 'is':
|
||||
return value.includes('*') ? null : value
|
||||
case 'endsWith':
|
||||
return `*${value}`
|
||||
case 'startsWith':
|
||||
return `${value}*`
|
||||
case 'contains':
|
||||
return `*${value}*`
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
interface ParsedTerm {
|
||||
field: FieldDef
|
||||
subject: string
|
||||
op: OperatorId
|
||||
value: string
|
||||
}
|
||||
|
||||
const CALL_OPS: Record<string, OperatorId> = {
|
||||
geoip: 'geoip',
|
||||
geosite: 'geosite',
|
||||
cidr: 'cidr',
|
||||
}
|
||||
|
||||
const COMPARE_OPS: Record<string, OperatorId> = {
|
||||
'==': 'is',
|
||||
contains: 'contains',
|
||||
startsWith: 'startsWith',
|
||||
endsWith: 'endsWith',
|
||||
matches: 'regex',
|
||||
}
|
||||
|
||||
/** All expressions that can appear on the left hand side of a comparison. */
|
||||
function subjects(expected?: string): { subject: string; field: FieldDef }[] {
|
||||
const out: { subject: string; field: FieldDef }[] = []
|
||||
for (const field of FIELDS) {
|
||||
if (field.item) out.push({ subject: field.item, field })
|
||||
if (field.expr) out.push({ subject: field.expr, field })
|
||||
}
|
||||
// Longest match wins, but the subject already established for the condition
|
||||
// is always tried first so that ambiguous fields stay stable.
|
||||
return out.sort(
|
||||
(a, b) =>
|
||||
Number(b.subject === expected) - Number(a.subject === expected) ||
|
||||
b.subject.length - a.subject.length,
|
||||
)
|
||||
}
|
||||
|
||||
function parseTerm(input: string, expected?: string): ParsedTerm | null {
|
||||
const source = input.trim()
|
||||
|
||||
// Analyzer presence: `tls != nil`
|
||||
const detected = /^([a-z][\w]*)\s*!=\s*nil$/.exec(source)
|
||||
if (detected) {
|
||||
const field = FIELDS.find((f) => f.type === 'analyzer')
|
||||
if (field) return { field, subject: '', op: 'detected', value: detected[1] }
|
||||
}
|
||||
|
||||
// Domain-or-subdomain: (S == "v" || S endsWith ".v")
|
||||
if (source.startsWith('(') && isWrapped(source)) {
|
||||
const inner = splitTop(unwrap(source), '||')
|
||||
if (inner.length === 2) {
|
||||
const a = parseTerm(inner[0].trim(), expected)
|
||||
const b = parseTerm(inner[1].trim(), expected)
|
||||
if (
|
||||
a &&
|
||||
b &&
|
||||
a.op === 'is' &&
|
||||
b.op === 'endsWith' &&
|
||||
a.field.key === b.field.key &&
|
||||
b.value === `.${a.value}`
|
||||
) {
|
||||
return { ...a, op: 'subdomain' }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
for (const [name, op] of Object.entries(CALL_OPS)) {
|
||||
const args = matchCall(source, name)
|
||||
if (args === null) continue
|
||||
const parts = splitTop(args, ',').map((s) => s.trim())
|
||||
if (parts.length !== 2) return null
|
||||
const entry = subjects(expected).find((s) => s.subject === parts[0])
|
||||
const value = unquote(parts[1])
|
||||
if (!entry || value === null) return null
|
||||
return { field: entry.field, subject: entry.subject, op, value }
|
||||
}
|
||||
|
||||
for (const { subject, field } of subjects(expected)) {
|
||||
if (!source.startsWith(subject)) continue
|
||||
const rest = source.slice(subject.length).trim()
|
||||
// Wildcard "*" compiles to `S != ""`.
|
||||
if (rest === '!= ""') return { field, subject, op: 'wildcard', value: '*' }
|
||||
for (const [token, op] of Object.entries(COMPARE_OPS)) {
|
||||
if (!rest.startsWith(token)) continue
|
||||
const literal = rest.slice(token.length).trim()
|
||||
if (field.type === 'port' && /^\d+$/.test(literal) && op === 'is') {
|
||||
return { field, subject, op, value: literal }
|
||||
}
|
||||
const value = unquote(literal)
|
||||
if (value === null) continue
|
||||
return { field, subject, op, value }
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Returns the arguments of `name(...)` when the input is exactly that call. */
|
||||
function matchCall(source: string, name: string): string | null {
|
||||
const prefix = `${name}(`
|
||||
if (!source.startsWith(prefix) || !source.endsWith(')')) return null
|
||||
const args = source.slice(prefix.length, -1)
|
||||
return balanced(args) ? args : null
|
||||
}
|
||||
|
||||
function unquote(literal: string): string | null {
|
||||
if (literal.length < 2 || literal[0] !== '"' || literal[literal.length - 1] !== '"') return null
|
||||
const body = literal.slice(1, -1)
|
||||
let out = ''
|
||||
for (let i = 0; i < body.length; i++) {
|
||||
if (body[i] === '\\' && i + 1 < body.length) {
|
||||
i++
|
||||
out += body[i]
|
||||
} else if (body[i] === '"') {
|
||||
return null
|
||||
} else {
|
||||
out += body[i]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Splits on a top level operator, ignoring anything nested or quoted. */
|
||||
export function splitTop(source: string, operator: string): string[] {
|
||||
const parts: string[] = []
|
||||
let depth = 0
|
||||
let quoted = false
|
||||
let start = 0
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i]
|
||||
if (quoted) {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quoted = false
|
||||
continue
|
||||
}
|
||||
if (ch === '"') quoted = true
|
||||
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
||||
else if (ch === ')' || ch === ']' || ch === '}') depth--
|
||||
else if (depth === 0 && source.startsWith(operator, i)) {
|
||||
parts.push(source.slice(start, i))
|
||||
i += operator.length - 1
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
parts.push(source.slice(start))
|
||||
return parts.map((p) => p.trim()).filter((p) => p.length > 0)
|
||||
}
|
||||
|
||||
function balanced(source: string): boolean {
|
||||
let depth = 0
|
||||
let quoted = false
|
||||
for (let i = 0; i < source.length; i++) {
|
||||
const ch = source[i]
|
||||
if (quoted) {
|
||||
if (ch === '\\') i++
|
||||
else if (ch === '"') quoted = false
|
||||
continue
|
||||
}
|
||||
if (ch === '"') quoted = true
|
||||
else if (ch === '(' || ch === '[' || ch === '{') depth++
|
||||
else if (ch === ')' || ch === ']' || ch === '}') {
|
||||
depth--
|
||||
if (depth < 0) return false
|
||||
}
|
||||
}
|
||||
return depth === 0 && !quoted
|
||||
}
|
||||
|
||||
/** True when the whole string is wrapped in one pair of parentheses. */
|
||||
function isWrapped(source: string): boolean {
|
||||
if (!source.startsWith('(') || !source.endsWith(')')) return false
|
||||
return balanced(source.slice(1, -1))
|
||||
}
|
||||
|
||||
function unwrap(source: string): string {
|
||||
let out = source.trim()
|
||||
while (isWrapped(out)) out = out.slice(1, -1).trim()
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { OperatorId } from './fields'
|
||||
|
||||
export type MatchMode = 'and' | 'or'
|
||||
|
||||
export interface Condition {
|
||||
id: string
|
||||
field: string
|
||||
op: OperatorId
|
||||
negate: boolean
|
||||
/** One entry per value; the `range` operator uses exactly two. */
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface RuleBuilderState {
|
||||
mode: MatchMode
|
||||
conditions: Condition[]
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
|
||||
export function newCondition(partial: Partial<Condition> = {}): Condition {
|
||||
counter += 1
|
||||
return {
|
||||
id: `c${counter}`,
|
||||
field: 'tls.sni',
|
||||
op: 'subdomain',
|
||||
negate: false,
|
||||
values: [],
|
||||
...partial,
|
||||
}
|
||||
}
|
||||
|
||||
export function emptyBuilderState(): RuleBuilderState {
|
||||
return { mode: 'and', conditions: [newCondition()] }
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import type { OperatorId, ValueType } from './fields'
|
||||
|
||||
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
|
||||
|
||||
export function isIPv4(value: string): boolean {
|
||||
const m = IPV4.exec(value)
|
||||
return m ? m.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part) : false
|
||||
}
|
||||
|
||||
export function isIPv6(value: string): boolean {
|
||||
if (!value.includes(':')) return false
|
||||
// Reject obvious garbage; the engine does the authoritative parsing.
|
||||
if (!/^[0-9a-fA-F:.]+$/.test(value)) return false
|
||||
return (value.match(/::/g) ?? []).length <= 1 && value.split(':').length <= 9
|
||||
}
|
||||
|
||||
export function isIP(value: string): boolean {
|
||||
return isIPv4(value) || isIPv6(value)
|
||||
}
|
||||
|
||||
export function isCIDR(value: string): boolean {
|
||||
const slash = value.lastIndexOf('/')
|
||||
if (slash < 0) return false
|
||||
const ip = value.slice(0, slash)
|
||||
const bits = value.slice(slash + 1)
|
||||
if (!/^\d{1,3}$/.test(bits)) return false
|
||||
const prefix = Number(bits)
|
||||
if (isIPv4(ip)) return prefix <= 32
|
||||
if (isIPv6(ip)) return prefix <= 128
|
||||
return false
|
||||
}
|
||||
|
||||
export function isPort(value: string): boolean {
|
||||
return /^\d{1,5}$/.test(value) && Number(value) <= 65535
|
||||
}
|
||||
|
||||
/** Returns an error message for a value, or "" when it is acceptable. */
|
||||
export function validateValue(type: ValueType, op: OperatorId, value: string): string {
|
||||
const v = value.trim()
|
||||
if (!v) return ''
|
||||
switch (op) {
|
||||
case 'cidr':
|
||||
return isCIDR(v) ? '' : t('rules.invalidCidr')
|
||||
case 'regex':
|
||||
try {
|
||||
new RegExp(v)
|
||||
return ''
|
||||
} catch {
|
||||
return t('rules.invalidRegex')
|
||||
}
|
||||
case 'geoip':
|
||||
case 'geosite':
|
||||
return /^[a-z0-9-]+(@[a-z0-9-]+)*$/i.test(v) ? '' : t('rules.invalidGeo')
|
||||
case 'wildcard':
|
||||
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
|
||||
default:
|
||||
break
|
||||
}
|
||||
// A star only means something with the wildcard operator; anywhere else it
|
||||
// would be matched literally, which is never what the user meant.
|
||||
if ((type === 'domain' || type === 'text') && v.includes('*')) {
|
||||
return t('rules.starHint')
|
||||
}
|
||||
switch (type) {
|
||||
case 'ip':
|
||||
return isIP(v) ? '' : t('rules.invalidIp')
|
||||
case 'port':
|
||||
return isPort(v) ? '' : t('rules.invalidPort')
|
||||
case 'domain':
|
||||
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user