4 Commits
Author SHA1 Message Date
mei 1c9daac28d feat: make install 2026-07-27 11:09:03 +08:00
mei a6917146b8 fix 2026-07-27 10:45:47 +08:00
meiandClaude Opus 5 f7ad3aac95 feat(web): visual rule builder with geo, CIDR and wildcard pickers
The rule editor no longer requires writing expr by hand. Conditions are rows of
field + operator + values joined with AND or OR, each row negatable, and the
generated expression is shown live and validated by the engine before a rule is
accepted. Raw expression and YAML editing are still available.

Fields cover the connection (protocol, source/destination IP and port), domains
(TLS SNI, QUIC SNI, DNS query name), HTTP (host, path, method, User-Agent) and
protocol detection. Operators cover CIDR membership, GeoIP countries, GeoSite
categories, port ranges, wildcards, substrings and regular expressions.
Multiple values in a row are OR-ed, so one row holds a whole domain or country
list.

Wildcards compile to the cheapest expression that matches them: *.x.com
becomes endsWith, x.* startsWith, *ad* contains, and only a star in the
middle falls back to a regular expression. Values are validated as they are
typed, including a hint when a star is used with an operator that would match
it literally.

Country and category pickers are backed by the databases the engine actually
loaded, via a new GET /api/v1/geo endpoint (cached, loaded on demand) built on
new listing methods in the geo package. Country names and flags come from
Intl.DisplayNames, so no name table is shipped. Note that the v2geo format has
no AS numbers; the provider groups it does contain (cloudflare, google,
telegram, ...) are listed alongside the countries.

Opening an existing rule parses its expression back into conditions. Anything
the builder cannot represent opens in the expression editor with a warning
rather than being rewritten. ruleset/expr_test.go pins the canonical
expressions the builder generates and compiles them with the real engine, and
the devserver now uses the real ruleset compiler so the same errors show up
during frontend work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:48:56 +08:00
meiandClaude Opus 5 5a7722d1d2 feat: add embedded web UI
Adds an optional web dashboard served by OpenGFW itself, enabled with a new
`web` section in the config file.

Backend (web package, decoupled from engine/io so it builds on any OS):
- hub.go collects statistics off the engine logger callbacks: atomic counters,
  two ring-buffered time series (10s and 1min buckets), top N hosts/blocked
  destinations/rules/analyzers, and a 512 entry event buffer fanned out to
  connected clients over SSE. Slow clients drop frames instead of blocking
  the engine.
- api.go exposes /api/v1 for info, meta, metrics, events, the SSE stream and
  ruleset read/validate/replace.
- auth.go implements password login with in-memory session tokens and login
  rate limiting. Mutating endpoints require the bearer token (the session
  cookie is only accepted for GET), which makes them CSRF-safe.
- cmd/web.go implements the rule manager: rules are compiled before anything
  is written, the file is replaced atomically and the engine is hot reloaded.
  The SIGHUP handler now shares that same path.
- web/devserver serves the UI with synthetic traffic for frontend work on
  machines where the engine itself cannot be built.

Frontend (web/frontend, Vue 3 + Vite + Tailwind CSS v4 + Reka UI):
dashboard, live event feed with analyzer property inspection, visual and YAML
rule editors, analyzer overview and settings. Responsive down to phone sizes
with a bottom tab bar and bottom-sheet dialogs, plus light/dark themes and
English/Chinese translations.

The built UI in web/dist is committed and embedded with go:embed so that
`go build` works without Node; CI builds the frontend and checks that the
committed output is up to date.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:09:11 +08:00
109 changed files with 10566 additions and 23 deletions
+29
View File
@@ -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
+13 -1
View File
@@ -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
View File
@@ -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/
+76
View File
@@ -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
View File
@@ -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) を参照してください。
## ユースケース
+27 -1
View File
@@ -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
View File
@@ -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)。
## 使用场景
+70 -18
View File
@@ -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
@@ -249,18 +265,24 @@ func runMain(cmd *cobra.Command, args []string) {
}
defer engineConfig.IO.Close() // Make sure to close IO on exit
// Ruleset
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
if err != nil {
logger.Fatal("failed to load rules", zap.Error(err))
// Statistics hub for the web UI
if config.Web.Enabled {
hub = web.NewHub()
}
// Ruleset
rsConfig := &ruleset.BuiltinConfig{
Logger: &rulesetLogger{},
GeoSiteFilename: config.Ruleset.GeoSite,
GeoIpFilename: config.Ruleset.GeoIp,
ProtectedDialContext: engineConfig.IO.ProtectedDialContext,
}
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
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))
}
rs, _, err := rm.Compile(string(rawRules))
if err != nil {
logger.Fatal("failed to compile rules", zap.Error(err))
}
@@ -271,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())
@@ -289,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)))
}
@@ -315,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),
@@ -331,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()),
@@ -340,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()),
@@ -349,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),
@@ -357,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()),
@@ -366,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()),
@@ -375,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()),
@@ -406,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),
@@ -415,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
View File
@@ -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
View File
@@ -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
View File
+64
View File
@@ -2,6 +2,7 @@ package geo
import (
"net"
"sort"
"strings"
"sync"
)
@@ -97,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
+6 -1
View File
@@ -42,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
}
+76
View File
@@ -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) {}
+135
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
+287
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1 @@
import{aB as h,aC as x,m as v,g as p,aD as R,al as S,d as g,aj as b,ap as w,aE as y,x as P,aF as k}from"./index-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};
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};
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 _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -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
+1
View File
@@ -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};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
View File
@@ -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

+33
View File
@@ -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>
+10
View File
@@ -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
+32
View File
@@ -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>
+2501
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+11
View File
@@ -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

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Toaster from '@/components/Toaster.vue'
</script>
<template>
<RouterView />
<Toaster />
</template>
+208
View File
@@ -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>
+117
View File
@@ -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>
+42
View File
@@ -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>
+46
View File
@@ -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>
+49
View File
@@ -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>
+52
View File
@@ -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>
+39
View File
@@ -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>
+61
View File
@@ -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>
+18
View File
@@ -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>
+22
View File
@@ -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>
+21
View File
@@ -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>
+71
View File
@@ -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>
+69
View File
@@ -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>
+26
View File
@@ -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>
+18
View File
@@ -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>
+20
View File
@@ -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'
+35
View File
@@ -0,0 +1,35 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { GeoData } from '@/lib/types'
const geo = ref<GeoData | null>(null)
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
const loading = ref(false)
let pending: Promise<void> | null = null
/** Loads the geo database listing once and shares it across components. */
async function load(force = false): Promise<void> {
if (geo.value && !force) return
if (pending) return pending
loading.value = true
pending = (async () => {
try {
const data = await api.geo(force)
geo.value = data
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
error.value = { ip: message, site: message }
geo.value = { ip: [], site: [] }
} finally {
loading.value = false
pending = null
}
})()
return pending
}
export function useGeo() {
return { geo, error, loading, load }
}
+105
View File
@@ -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,
}
}
+46
View File
@@ -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'
},
}
}
+33
View File
@@ -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 }
}
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}
+259
View File
@@ -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',
},
}
+46
View File
@@ -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'
+257
View File
@@ -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: '错误',
},
}
+112
View File
@@ -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`
+48
View File
@@ -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
}
+41
View File
@@ -0,0 +1,41 @@
/** Helpers for displaying geo database keys. */
const displayNames = new Map<string, Intl.DisplayNames>()
function names(lang: string): Intl.DisplayNames | null {
const locale = lang === 'zh' ? 'zh-CN' : 'en'
if (!displayNames.has(locale)) {
try {
displayNames.set(locale, new Intl.DisplayNames([locale], { type: 'region' }))
} catch {
return null
}
}
return displayNames.get(locale) ?? null
}
/**
* Localized country name for a two letter code, or null when the code is not a
* country. GeoIP databases also contain provider groups such as "cloudflare".
*/
export function regionName(code: string, lang: string): string | null {
if (!/^[a-z]{2}$/i.test(code)) return null
const upper = code.toUpperCase()
try {
const name = names(lang)?.of(upper)
return name && name !== upper ? name : null
} catch {
return null
}
}
/** Flag emoji for a two letter country code, empty for anything else. */
export function flagOf(code: string): string {
if (!/^[a-z]{2}$/i.test(code)) return ''
const base = 0x1f1e6
const upper = code.toUpperCase()
return String.fromCodePoint(
base + upper.charCodeAt(0) - 65,
base + upper.charCodeAt(1) - 65,
)
}
+113
View File
@@ -0,0 +1,113 @@
import { FIELD_MAP, type FieldDef, type OperatorId } from './fields'
import type { Condition, RuleBuilderState } from './types'
/** Quotes a value as an expr string literal. */
export function quote(value: string): string {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
const REGEX_SPECIALS = /[.+?^${}()|[\]\\]/g
/** Converts a `*.example.com` style pattern into an anchored regular expression. */
export function wildcardToRegex(pattern: string): string {
return `^${pattern.replace(REGEX_SPECIALS, '\\$&').replace(/\*/g, '.*')}$`
}
export interface WildcardExplain {
kind: 'exact' | 'suffix' | 'prefix' | 'contains' | 'regex' | 'any'
/** The meaningful part of the pattern, e.g. ".example.com" for "*.example.com". */
value: string
}
/** Describes how a wildcard pattern will be matched, for the inline hint. */
export function explainWildcard(pattern: string): WildcardExplain {
const p = pattern.trim()
if (p === '*') return { kind: 'any', value: '' }
if (!p.includes('*')) return { kind: 'exact', value: p }
const starts = p.startsWith('*')
const ends = p.endsWith('*')
const core = p.replace(/^\*/, '').replace(/\*$/, '')
if (core.includes('*')) return { kind: 'regex', value: wildcardToRegex(p) }
if (starts && ends) return { kind: 'contains', value: core }
if (starts) return { kind: 'suffix', value: core }
if (ends) return { kind: 'prefix', value: core }
return { kind: 'regex', value: wildcardToRegex(p) }
}
function wildcardTerm(subject: string, pattern: string): string {
const info = explainWildcard(pattern)
switch (info.kind) {
case 'any':
return `${subject} != ""`
case 'exact':
return `${subject} == ${quote(info.value)}`
case 'suffix':
return `${subject} endsWith ${quote(info.value)}`
case 'prefix':
return `${subject} startsWith ${quote(info.value)}`
case 'contains':
return `${subject} contains ${quote(info.value)}`
case 'regex':
return `${subject} matches ${quote(info.value)}`
}
}
/** Builds the expression for one value of a condition. */
function term(field: FieldDef, op: OperatorId, subject: string, value: string): string {
const v = value.trim()
switch (op) {
case 'is':
return field.type === 'port' ? `${subject} == ${v}` : `${subject} == ${quote(v)}`
case 'subdomain':
return `(${subject} == ${quote(v)} || ${subject} endsWith ${quote('.' + v)})`
case 'wildcard':
return wildcardTerm(subject, v)
case 'contains':
case 'startsWith':
case 'endsWith':
return `${subject} ${op} ${quote(v)}`
case 'regex':
return `${subject} matches ${quote(v)}`
case 'geosite':
return `geosite(${subject}, ${quote(v.toLowerCase())})`
case 'cidr':
return `cidr(${subject}, ${quote(v)})`
case 'geoip':
return `geoip(${subject}, ${quote(v.toLowerCase())})`
case 'detected':
return `${v} != nil`
case 'range':
return ''
}
}
/** Compiles a single condition into an expression, or "" when incomplete. */
export function compileCondition(condition: Condition): string {
const field = FIELD_MAP[condition.field]
if (!field) return ''
const values = condition.values.map((v) => v.trim()).filter(Boolean)
let body: string
if (condition.op === 'range') {
const [from, to] = condition.values
if (!from?.trim() || !to?.trim()) return ''
body = `(${field.expr} >= ${from.trim()} && ${field.expr} <= ${to.trim()})`
} else {
if (values.length === 0) return ''
const subject = field.iter ? (field.item as string) : (field.expr as string)
const terms = values.map((v) => term(field, condition.op, subject, v))
const joined = terms.length > 1 ? `(${terms.join(' || ')})` : terms[0]
body = field.iter ? `any(${field.iter}, {${joined}})` : joined
}
if (!condition.negate) return body
return body.startsWith('(') && body.endsWith(')') ? `!${body}` : `!(${body})`
}
/** Compiles the whole builder state into a rule expression. */
export function compileRule(state: RuleBuilderState): string {
const parts = state.conditions.map(compileCondition).filter(Boolean)
if (parts.length === 0) return ''
if (parts.length === 1) return parts[0]
return parts.join(state.mode === 'and' ? ' && ' : ' || ')
}
+126
View File
@@ -0,0 +1,126 @@
/**
* Catalog of the fields and operators the visual rule builder offers.
*
* Every field maps to a snippet of the expr expression language used by the
* engine. Optional chaining (`a?.b`) keeps rules safe when an analyzer did not
* produce any property for a stream, and `string(...)` turns a missing value
* into an empty string instead of an error.
*/
export type ValueType = 'proto' | 'ip' | 'port' | 'domain' | 'text' | 'analyzer'
export type OperatorId =
| 'is'
| 'subdomain'
| 'wildcard'
| 'contains'
| 'startsWith'
| 'endsWith'
| 'regex'
| 'geosite'
| 'cidr'
| 'geoip'
| 'range'
| 'detected'
export interface FieldDef {
key: string
/** Group used to organise the field picker. */
group: 'basic' | 'domain' | 'http' | 'protocol'
type: ValueType
/** Expression evaluating to the value, already coerced to a string. */
expr?: string
/** Analyzer this field depends on, shown as a hint in the UI. */
analyzer?: string
/**
* Set for fields that live in an array: `iter` is the list expression and
* `item` the expression of a single element inside the predicate.
*/
iter?: string
item?: string
}
export const FIELDS: FieldDef[] = [
{ key: 'proto', group: 'basic', type: 'proto', expr: 'proto' },
{ key: 'ip.dst', group: 'basic', type: 'ip', expr: 'ip.dst' },
{ key: 'ip.src', group: 'basic', type: 'ip', expr: 'ip.src' },
{ key: 'port.dst', group: 'basic', type: 'port', expr: 'port.dst' },
{ key: 'port.src', group: 'basic', type: 'port', expr: 'port.src' },
{ key: 'tls.sni', group: 'domain', type: 'domain', expr: 'string(tls?.req?.sni)', analyzer: 'tls' },
{
key: 'quic.sni',
group: 'domain',
type: 'domain',
expr: 'string(quic?.req?.sni)',
analyzer: 'quic',
},
{
key: 'dns.name',
group: 'domain',
type: 'domain',
analyzer: 'dns',
iter: 'dns?.questions ?? []',
item: 'string(.name)',
},
{
key: 'http.host',
group: 'http',
type: 'domain',
expr: 'string(http?.req?.headers?.host)',
analyzer: 'http',
},
{ key: 'http.path', group: 'http', type: 'text', expr: 'string(http?.req?.path)', analyzer: 'http' },
{
key: 'http.method',
group: 'http',
type: 'text',
expr: 'string(http?.req?.method)',
analyzer: 'http',
},
{
key: 'http.ua',
group: 'http',
type: 'text',
expr: 'string(get(http?.req?.headers, "user-agent"))',
analyzer: 'http',
},
{ key: 'analyzer', group: 'protocol', type: 'analyzer' },
]
export const FIELD_MAP: Record<string, FieldDef> = Object.fromEntries(
FIELDS.map((f) => [f.key, f]),
)
/** Operators offered for each value type, in menu order. */
export const OPERATORS: Record<ValueType, OperatorId[]> = {
proto: ['is'],
ip: ['cidr', 'geoip', 'is'],
port: ['is', 'range'],
domain: ['subdomain', 'wildcard', 'is', 'contains', 'endsWith', 'startsWith', 'regex', 'geosite'],
text: ['contains', 'is', 'startsWith', 'endsWith', 'regex'],
analyzer: ['detected'],
}
/** Operators whose value is picked from a list rather than typed. */
export const PICKER_OPERATORS: Partial<Record<OperatorId, 'geoip' | 'geosite'>> = {
geoip: 'geoip',
geosite: 'geosite',
}
export const PROTO_VALUES = ['tcp', 'udp']
/** `range` takes exactly two values (from and to); everything else is a list. */
export function isRangeOperator(op: OperatorId): boolean {
return op === 'range'
}
export function defaultOperator(type: ValueType): OperatorId {
return OPERATORS[type][0]
}
export function operatorsFor(field: FieldDef): OperatorId[] {
return OPERATORS[field.type]
}
+322
View File
@@ -0,0 +1,322 @@
import { FIELDS, type FieldDef, type OperatorId } from './fields'
import { newCondition } from './types'
import type { Condition, RuleBuilderState } from './types'
/**
* Best-effort conversion of an expression back into builder state.
*
* It understands the canonical shapes produced by compile.ts, which covers
* every rule created through the builder. Hand written expressions that use
* anything else simply return null, and the UI falls back to the expression
* editor instead of silently rewriting them.
*/
export function parseRule(expr: string): RuleBuilderState | null {
const source = expr.trim()
if (!source) return null
let mode: 'and' | 'or' = 'and'
let parts = splitTop(source, '&&')
if (parts.length === 1) {
const or = splitTop(source, '||')
if (or.length > 1) {
mode = 'or'
parts = or
}
}
const conditions: Condition[] = []
for (const part of parts) {
const condition = parseCondition(part)
if (!condition) return null
conditions.push(condition)
}
return { mode, conditions }
}
function parseCondition(input: string): Condition | null {
let source = input.trim()
let negate = false
if (source.startsWith('!')) {
negate = true
source = source.slice(1).trim()
}
// A single value, including the parenthesised "domain or subdomain" form.
const single = parseTerm(source)
if (single) {
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
}
const body = unwrap(source)
// Array fields: any(<iter>, {<predicate>})
const anyMatch = matchCall(body, 'any')
if (anyMatch !== null) {
const [iter, closure] = splitTop(anyMatch, ',').map((s) => s.trim())
if (!closure?.startsWith('{') || !closure.endsWith('}')) return null
const field = FIELDS.find((f) => f.iter === iter)
if (!field) return null
return finish(field, closure.slice(1, -1).trim(), negate, field.item as string)
}
// Port range: (port.dst >= 1 && port.dst <= 2)
const range = splitTop(body, '&&')
if (range.length === 2) {
const from = /^(\S+)\s*>=\s*(\d+)$/.exec(range[0].trim())
const to = /^(\S+)\s*<=\s*(\d+)$/.exec(range[1].trim())
if (from && to && from[1] === to[1]) {
const field = FIELDS.find((f) => f.expr === from[1] && f.type === 'port')
if (!field) return null
return newCondition({ field: field.key, op: 'range', negate, values: [from[2], to[2]] })
}
return null
}
if (range.length > 1) return null
return finish(null, body, negate)
}
/**
* Parses the values of a condition, which are OR-ed together and must all use
* the same field and operator.
*/
function finish(
expected: FieldDef | null,
body: string,
negate: boolean,
subject?: string,
): Condition | null {
const single = parseTerm(body.trim(), subject)
if (single && (!expected || single.field.key === expected.key)) {
return newCondition({ field: single.field.key, op: single.op, negate, values: [single.value] })
}
const parts = splitTop(unwrap(body), '||')
if (parts.length < 2) return null
const terms: ParsedTerm[] = []
let field = expected
for (const part of parts) {
const parsed = parseTerm(part.trim(), subject)
if (!parsed) return null
if (!field) field = parsed.field
if (parsed.field.key !== field.key) return null
terms.push(parsed)
}
if (!field) return null
const ops = new Set(terms.map((t) => t.op))
if (ops.size === 1) {
return newCondition({
field: field.key,
op: terms[0].op,
negate,
values: terms.map((t) => t.value),
})
}
// Values of a wildcard condition compile to different operators depending on
// where the star sits, so fold mixed operators back into wildcard patterns.
const patterns = terms.map((t) => toWildcard(t.op, t.value))
if (patterns.some((p) => p === null)) return null
return newCondition({ field: field.key, op: 'wildcard', negate, values: patterns as string[] })
}
/** Inverse of the wildcard compilation, used when a condition mixes operators. */
function toWildcard(op: OperatorId, value: string): string | null {
switch (op) {
case 'wildcard':
return value
case 'is':
return value.includes('*') ? null : value
case 'endsWith':
return `*${value}`
case 'startsWith':
return `${value}*`
case 'contains':
return `*${value}*`
default:
return null
}
}
interface ParsedTerm {
field: FieldDef
subject: string
op: OperatorId
value: string
}
const CALL_OPS: Record<string, OperatorId> = {
geoip: 'geoip',
geosite: 'geosite',
cidr: 'cidr',
}
const COMPARE_OPS: Record<string, OperatorId> = {
'==': 'is',
contains: 'contains',
startsWith: 'startsWith',
endsWith: 'endsWith',
matches: 'regex',
}
/** All expressions that can appear on the left hand side of a comparison. */
function subjects(expected?: string): { subject: string; field: FieldDef }[] {
const out: { subject: string; field: FieldDef }[] = []
for (const field of FIELDS) {
if (field.item) out.push({ subject: field.item, field })
if (field.expr) out.push({ subject: field.expr, field })
}
// Longest match wins, but the subject already established for the condition
// is always tried first so that ambiguous fields stay stable.
return out.sort(
(a, b) =>
Number(b.subject === expected) - Number(a.subject === expected) ||
b.subject.length - a.subject.length,
)
}
function parseTerm(input: string, expected?: string): ParsedTerm | null {
const source = input.trim()
// Analyzer presence: `tls != nil`
const detected = /^([a-z][\w]*)\s*!=\s*nil$/.exec(source)
if (detected) {
const field = FIELDS.find((f) => f.type === 'analyzer')
if (field) return { field, subject: '', op: 'detected', value: detected[1] }
}
// Domain-or-subdomain: (S == "v" || S endsWith ".v")
if (source.startsWith('(') && isWrapped(source)) {
const inner = splitTop(unwrap(source), '||')
if (inner.length === 2) {
const a = parseTerm(inner[0].trim(), expected)
const b = parseTerm(inner[1].trim(), expected)
if (
a &&
b &&
a.op === 'is' &&
b.op === 'endsWith' &&
a.field.key === b.field.key &&
b.value === `.${a.value}`
) {
return { ...a, op: 'subdomain' }
}
}
return null
}
for (const [name, op] of Object.entries(CALL_OPS)) {
const args = matchCall(source, name)
if (args === null) continue
const parts = splitTop(args, ',').map((s) => s.trim())
if (parts.length !== 2) return null
const entry = subjects(expected).find((s) => s.subject === parts[0])
const value = unquote(parts[1])
if (!entry || value === null) return null
return { field: entry.field, subject: entry.subject, op, value }
}
for (const { subject, field } of subjects(expected)) {
if (!source.startsWith(subject)) continue
const rest = source.slice(subject.length).trim()
// Wildcard "*" compiles to `S != ""`.
if (rest === '!= ""') return { field, subject, op: 'wildcard', value: '*' }
for (const [token, op] of Object.entries(COMPARE_OPS)) {
if (!rest.startsWith(token)) continue
const literal = rest.slice(token.length).trim()
if (field.type === 'port' && /^\d+$/.test(literal) && op === 'is') {
return { field, subject, op, value: literal }
}
const value = unquote(literal)
if (value === null) continue
return { field, subject, op, value }
}
return null
}
return null
}
/** Returns the arguments of `name(...)` when the input is exactly that call. */
function matchCall(source: string, name: string): string | null {
const prefix = `${name}(`
if (!source.startsWith(prefix) || !source.endsWith(')')) return null
const args = source.slice(prefix.length, -1)
return balanced(args) ? args : null
}
function unquote(literal: string): string | null {
if (literal.length < 2 || literal[0] !== '"' || literal[literal.length - 1] !== '"') return null
const body = literal.slice(1, -1)
let out = ''
for (let i = 0; i < body.length; i++) {
if (body[i] === '\\' && i + 1 < body.length) {
i++
out += body[i]
} else if (body[i] === '"') {
return null
} else {
out += body[i]
}
}
return out
}
/** Splits on a top level operator, ignoring anything nested or quoted. */
export function splitTop(source: string, operator: string): string[] {
const parts: string[] = []
let depth = 0
let quoted = false
let start = 0
for (let i = 0; i < source.length; i++) {
const ch = source[i]
if (quoted) {
if (ch === '\\') i++
else if (ch === '"') quoted = false
continue
}
if (ch === '"') quoted = true
else if (ch === '(' || ch === '[' || ch === '{') depth++
else if (ch === ')' || ch === ']' || ch === '}') depth--
else if (depth === 0 && source.startsWith(operator, i)) {
parts.push(source.slice(start, i))
i += operator.length - 1
start = i + 1
}
}
parts.push(source.slice(start))
return parts.map((p) => p.trim()).filter((p) => p.length > 0)
}
function balanced(source: string): boolean {
let depth = 0
let quoted = false
for (let i = 0; i < source.length; i++) {
const ch = source[i]
if (quoted) {
if (ch === '\\') i++
else if (ch === '"') quoted = false
continue
}
if (ch === '"') quoted = true
else if (ch === '(' || ch === '[' || ch === '{') depth++
else if (ch === ')' || ch === ']' || ch === '}') {
depth--
if (depth < 0) return false
}
}
return depth === 0 && !quoted
}
/** True when the whole string is wrapped in one pair of parentheses. */
function isWrapped(source: string): boolean {
if (!source.startsWith('(') || !source.endsWith(')')) return false
return balanced(source.slice(1, -1))
}
function unwrap(source: string): string {
let out = source.trim()
while (isWrapped(out)) out = out.slice(1, -1).trim()
return out
}
+35
View File
@@ -0,0 +1,35 @@
import type { OperatorId } from './fields'
export type MatchMode = 'and' | 'or'
export interface Condition {
id: string
field: string
op: OperatorId
negate: boolean
/** One entry per value; the `range` operator uses exactly two. */
values: string[]
}
export interface RuleBuilderState {
mode: MatchMode
conditions: Condition[]
}
let counter = 0
export function newCondition(partial: Partial<Condition> = {}): Condition {
counter += 1
return {
id: `c${counter}`,
field: 'tls.sni',
op: 'subdomain',
negate: false,
values: [],
...partial,
}
}
export function emptyBuilderState(): RuleBuilderState {
return { mode: 'and', conditions: [newCondition()] }
}
+76
View File
@@ -0,0 +1,76 @@
import { t } from '@/i18n'
import type { OperatorId, ValueType } from './fields'
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/
export function isIPv4(value: string): boolean {
const m = IPV4.exec(value)
return m ? m.slice(1).every((part) => Number(part) <= 255 && String(Number(part)) === part) : false
}
export function isIPv6(value: string): boolean {
if (!value.includes(':')) return false
// Reject obvious garbage; the engine does the authoritative parsing.
if (!/^[0-9a-fA-F:.]+$/.test(value)) return false
return (value.match(/::/g) ?? []).length <= 1 && value.split(':').length <= 9
}
export function isIP(value: string): boolean {
return isIPv4(value) || isIPv6(value)
}
export function isCIDR(value: string): boolean {
const slash = value.lastIndexOf('/')
if (slash < 0) return false
const ip = value.slice(0, slash)
const bits = value.slice(slash + 1)
if (!/^\d{1,3}$/.test(bits)) return false
const prefix = Number(bits)
if (isIPv4(ip)) return prefix <= 32
if (isIPv6(ip)) return prefix <= 128
return false
}
export function isPort(value: string): boolean {
return /^\d{1,5}$/.test(value) && Number(value) <= 65535
}
/** Returns an error message for a value, or "" when it is acceptable. */
export function validateValue(type: ValueType, op: OperatorId, value: string): string {
const v = value.trim()
if (!v) return ''
switch (op) {
case 'cidr':
return isCIDR(v) ? '' : t('rules.invalidCidr')
case 'regex':
try {
new RegExp(v)
return ''
} catch {
return t('rules.invalidRegex')
}
case 'geoip':
case 'geosite':
return /^[a-z0-9-]+(@[a-z0-9-]+)*$/i.test(v) ? '' : t('rules.invalidGeo')
case 'wildcard':
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
default:
break
}
// A star only means something with the wildcard operator; anywhere else it
// would be matched literally, which is never what the user meant.
if ((type === 'domain' || type === 'text') && v.includes('*')) {
return t('rules.starHint')
}
switch (type) {
case 'ip':
return isIP(v) ? '' : t('rules.invalidIp')
case 'port':
return isPort(v) ? '' : t('rules.invalidPort')
case 'domain':
return /^[a-z0-9.*_-]+$/i.test(v) ? '' : t('rules.invalidDomain')
default:
return ''
}
}
+123
View File
@@ -0,0 +1,123 @@
export interface SelectOption {
value: string
label: string
}
export type ActionName = 'allow' | 'block' | 'drop' | 'modify' | 'maybe'
export type EventKind = 'action' | 'log' | 'error'
export interface LiveEvent {
seq: number
time: number
kind: EventKind
id: number
proto: string
srcIP: string
srcPort: number
dstIP: string
dstPort: number
action?: ActionName
rule?: string
host?: string
message?: string
props?: Record<string, Record<string, unknown>>
}
export interface Bucket {
time: number
tcp: number
udp: number
blocked: number
allowed: number
}
export interface NameCount {
name: string
count: number
}
export interface Metrics {
uptimeSeconds: number
workers: number
streams: { tcp: number; udp: number; total: number }
actions: { allow: number; block: number; drop: number; modify: number; maybe: number }
errors: number
short: Bucket[]
long: Bucket[]
topHosts: NameCount[]
topBlockedIPs: NameCount[]
topRules: NameCount[]
analyzers: NameCount[]
}
export interface ConfigDigest {
ioQueueSize: number
ioLocal: boolean
ioRST: boolean
workers: number
workerQueueSize: number
udpMaxStreams: number
geoip?: string
geosite?: string
}
export interface Info {
version: string
commit?: string
platform: string
goVersion: string
hostname: string
ruleFile: string
config: ConfigDigest
}
export interface AnalyzerInfo {
name: string
proto: string
}
export interface MetaInfo {
analyzers: AnalyzerInfo[]
modifiers: string[]
actions: string[]
functions: string[]
}
export interface GeoEntry {
code: string
count: number
attributes?: string[]
}
export interface GeoData {
ip: GeoEntry[]
site: GeoEntry[]
ipError?: string
siteError?: string
}
export interface RuleModifier {
name: string
args?: Record<string, unknown>
}
export interface Rule {
name: string
action?: string
log?: boolean
expr: string
modifier?: RuleModifier | null
}
export interface RulesResponse {
path: string
raw: string
rules: Rule[]
}
export interface ValidateResponse {
valid: boolean
error?: string
rules?: Rule[]
raw: string
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+14
View File
@@ -0,0 +1,14 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/style.css'
import { stopLive } from './composables/useLive'
import { auth } from './lib/api'
import router from './router'
auth.onUnauthorized(() => {
stopLive()
void router.replace({ name: 'login' })
})
createApp(App).use(router).mount('#app')
+38
View File
@@ -0,0 +1,38 @@
import { createRouter, createWebHistory } from 'vue-router'
import AppShell from '@/components/AppShell.vue'
import { auth } from '@/lib/api'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: LoginView, meta: { public: true } },
{
path: '/',
component: AppShell,
children: [
{ path: '', name: 'dashboard', component: () => import('@/views/DashboardView.vue') },
{ path: 'events', name: 'events', component: () => import('@/views/EventsView.vue') },
{ path: 'rules', name: 'rules', component: () => import('@/views/RulesView.vue') },
{
path: 'analyzers',
name: 'analyzers',
component: () => import('@/views/AnalyzersView.vue'),
},
{ path: 'settings', name: 'settings', component: () => import('@/views/SettingsView.vue') },
],
},
{ path: '/:pathMatch(.*)*', redirect: '/' },
],
})
router.beforeEach((to) => {
if (!to.meta.public && !auth.authenticated) {
return { name: 'login', query: to.fullPath === '/' ? undefined : { redirect: to.fullPath } }
}
if (to.name === 'login' && auth.authenticated) return { name: 'dashboard' }
return true
})
export default router
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
import { Boxes, FunctionSquare } from 'lucide-vue-next'
import { computed, onMounted, ref } from 'vue'
import PageHeader from '@/components/PageHeader.vue'
import {
Badge,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
} from '@/components/ui'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { api } from '@/lib/api'
import { compact } from '@/lib/format'
import type { MetaInfo } from '@/lib/types'
const { metrics } = useLive()
const meta = ref<MetaInfo | null>(null)
onMounted(async () => {
try {
meta.value = await api.meta()
} catch {
meta.value = { analyzers: [], modifiers: [], actions: [], functions: [] }
}
})
const hits = computed(() => {
const map = new Map<string, number>()
for (const item of metrics.value?.analyzers ?? []) map.set(item.name, item.count)
return map
})
const analyzers = computed(() =>
[...(meta.value?.analyzers ?? [])].sort(
(a, b) => (hits.value.get(b.name) ?? 0) - (hits.value.get(a.name) ?? 0),
),
)
</script>
<template>
<div>
<PageHeader :title="t('analyzers.title')" :description="t('analyzers.subtitle')" />
<div v-if="!meta" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Skeleton v-for="i in 8" :key="i" class="h-24" />
</div>
<template v-else>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
<Card v-for="analyzer in analyzers" :key="analyzer.name" class="p-3.5 sm:p-4">
<div class="flex items-center justify-between gap-2">
<span class="truncate font-mono text-sm font-medium">{{ analyzer.name }}</span>
<Badge :variant="analyzer.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ analyzer.proto }}
</Badge>
</div>
<p class="mt-2 text-xl font-semibold tabular-nums">
{{ compact(hits.get(analyzer.name) ?? 0) }}
</p>
<p class="text-muted-foreground text-xs">{{ t('analyzers.hits') }}</p>
</Card>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<Boxes class="size-4" />
{{ t('analyzers.modifiers') }}
</CardTitle>
<CardDescription>{{ t('analyzers.modifiersDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.modifiers" :key="name" variant="secondary" class="font-mono">
{{ name }}
</Badge>
<span v-if="!meta.modifiers.length" class="text-muted-foreground text-xs">—</span>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle class="flex items-center gap-2">
<FunctionSquare class="size-4" />
{{ t('analyzers.functions') }}
</CardTitle>
<CardDescription>{{ t('analyzers.functionsDesc') }}</CardDescription>
</CardHeader>
<CardContent class="flex flex-wrap gap-1.5">
<Badge v-for="name in meta.functions" :key="name" variant="secondary" class="font-mono">
{{ name }}()
</Badge>
</CardContent>
</Card>
</div>
</template>
</div>
</template>
+241
View File
@@ -0,0 +1,241 @@
<script setup lang="ts">
import { ArrowRight, Ban, Clock, Cpu, ShieldCheck, TriangleAlert, Waves } from 'lucide-vue-next'
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import BarList from '@/components/BarList.vue'
import EventItem from '@/components/EventItem.vue'
import PageHeader from '@/components/PageHeader.vue'
import StatCard from '@/components/StatCard.vue'
import TrafficChart from '@/components/TrafficChart.vue'
import {
Button,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
Skeleton,
Tabs,
TabsList,
TabsTrigger,
} from '@/components/ui'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { compact, duration, percent } from '@/lib/format'
const router = useRouter()
const { metrics, events } = useLive()
const range = ref('short')
const buckets = computed(
() => (range.value === 'short' ? metrics.value?.short : metrics.value?.long) ?? [],
)
const recent = computed(() => events.value.slice(0, 8))
const verdicts = computed(() => {
const a = metrics.value?.actions
if (!a) return []
const total = a.allow + a.block + a.drop + a.modify || 1
return [
{ key: 'allow', label: t('action.allow'), value: a.allow, color: 'bg-allow', total },
{ key: 'block', label: t('action.block'), value: a.block, color: 'bg-block', total },
{ key: 'drop', label: t('action.drop'), value: a.drop, color: 'bg-drop', total },
{ key: 'modify', label: t('action.modify'), value: a.modify, color: 'bg-modify', total },
].filter((v) => v.value > 0 || v.key === 'allow' || v.key === 'block')
})
const protocols = computed(() => {
const s = metrics.value?.streams
if (!s) return []
const total = s.total || 1
return [
{ key: 'tcp', label: t('dashboard.tcp'), value: s.tcp, color: 'bg-tcp', total },
{ key: 'udp', label: t('dashboard.udp'), value: s.udp, color: 'bg-udp', total },
]
})
const blockedTotal = computed(() => {
const a = metrics.value?.actions
return a ? a.block + a.drop : 0
})
</script>
<template>
<div>
<PageHeader :title="t('dashboard.title')" :description="t('dashboard.subtitle')" />
<div v-if="!metrics" class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<Skeleton v-for="i in 6" :key="i" class="h-20" />
</div>
<template v-else>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<StatCard
:label="t('dashboard.streams')"
:value="compact(metrics.streams.total)"
:hint="`${compact(metrics.streams.tcp)} / ${compact(metrics.streams.udp)}`"
:icon="Waves"
/>
<StatCard
:label="t('dashboard.blocked')"
:value="compact(blockedTotal)"
:hint="`${percent(blockedTotal, metrics.streams.total)}%`"
:icon="Ban"
tone="block"
/>
<StatCard
:label="t('dashboard.allowed')"
:value="compact(metrics.actions.allow)"
:icon="ShieldCheck"
tone="allow"
/>
<StatCard
:label="t('dashboard.modified')"
:value="compact(metrics.actions.modify)"
:icon="Waves"
tone="modify"
/>
<StatCard
:label="t('dashboard.uptime')"
:value="duration(metrics.uptimeSeconds)"
:icon="Clock"
/>
<StatCard
:label="metrics.errors > 0 ? t('dashboard.errors') : t('dashboard.workers')"
:value="metrics.errors > 0 ? compact(metrics.errors) : String(metrics.workers)"
:icon="metrics.errors > 0 ? TriangleAlert : Cpu"
:tone="metrics.errors > 0 ? 'drop' : 'default'"
/>
</div>
<div class="mt-3 grid gap-3 lg:grid-cols-3">
<Card class="lg:col-span-2">
<CardHeader class="flex-row items-start justify-between gap-3">
<div class="min-w-0">
<CardTitle>{{ t('dashboard.traffic') }}</CardTitle>
<CardDescription>{{ t('dashboard.trafficDesc') }}</CardDescription>
</div>
<Tabs v-model="range" class="shrink-0">
<TabsList class="h-8">
<TabsTrigger value="short" class="text-xs">{{ t('dashboard.range10m') }}</TabsTrigger>
<TabsTrigger value="long" class="text-xs">{{ t('dashboard.range1h') }}</TabsTrigger>
</TabsList>
</Tabs>
</CardHeader>
<CardContent>
<TrafficChart :buckets="buckets" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.verdicts') }}</CardTitle>
</CardHeader>
<CardContent class="flex flex-col gap-4">
<div>
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
<div
v-for="v in verdicts"
:key="v.key"
:class="v.color"
:style="{ width: `${(v.value / v.total) * 100}%` }"
/>
</div>
<div class="mt-2.5 flex flex-col gap-1.5">
<div
v-for="v in verdicts"
:key="v.key"
class="flex items-center gap-2 text-xs sm:text-[13px]"
>
<span :class="['size-2 rounded-full', v.color]" />
<span class="text-muted-foreground">{{ v.label }}</span>
<span class="ml-auto font-medium tabular-nums">{{ compact(v.value) }}</span>
</div>
</div>
</div>
<div>
<p class="text-muted-foreground mb-2 text-xs font-medium">
{{ t('dashboard.protocols') }}
</p>
<div class="bg-muted flex h-2 overflow-hidden rounded-full">
<div
v-for="p in protocols"
:key="p.key"
:class="p.color"
:style="{ width: `${(p.value / p.total) * 100}%` }"
/>
</div>
<div class="mt-2.5 flex flex-col gap-1.5">
<div
v-for="p in protocols"
:key="p.key"
class="flex items-center gap-2 text-xs sm:text-[13px]"
>
<span :class="['size-2 rounded-full', p.color]" />
<span class="text-muted-foreground">{{ p.label }}</span>
<span class="ml-auto font-medium tabular-nums">{{ compact(p.value) }}</span>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
<div class="mt-3 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.topHosts') }}</CardTitle>
<CardDescription>{{ t('dashboard.topHostsDesc') }}</CardDescription>
</CardHeader>
<CardContent>
<BarList :items="metrics.topHosts" :empty="t('common.empty')" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>{{ t('dashboard.topBlocked') }}</CardTitle>
</CardHeader>
<CardContent>
<BarList :items="metrics.topBlockedIPs" tone="block" :empty="t('common.empty')" />
</CardContent>
</Card>
<Card class="md:col-span-2 xl:col-span-1">
<CardHeader>
<CardTitle>{{ t('dashboard.topRules') }}</CardTitle>
</CardHeader>
<CardContent>
<BarList :items="metrics.topRules" tone="allow" :empty="t('common.empty')" />
</CardContent>
</Card>
</div>
<Card class="mt-3">
<CardHeader class="flex-row items-center justify-between">
<CardTitle>{{ t('dashboard.recent') }}</CardTitle>
<Button variant="ghost" size="sm" @click="router.push('/events')">
{{ t('dashboard.viewAll') }}
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="px-1.5 sm:px-2.5">
<div v-if="recent.length" class="flex flex-col">
<EventItem
v-for="event in recent"
:key="event.seq"
:event="event"
@select="router.push('/events')"
/>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ t('events.empty') }}
</p>
</CardContent>
</Card>
</template>
</div>
</template>
+216
View File
@@ -0,0 +1,216 @@
<script setup lang="ts">
import { Copy, Pause, Play, Search, Trash2 } from 'lucide-vue-next'
import { computed, ref, watch } from 'vue'
import ActionBadge from '@/components/ActionBadge.vue'
import EventItem from '@/components/EventItem.vue'
import PageHeader from '@/components/PageHeader.vue'
import { Badge, Button, Card, Input, Modal, Select } from '@/components/ui'
import { clearEvents, useLive } from '@/composables/useLive'
import { toast } from '@/composables/useToast'
import { t } from '@/i18n'
import { dateTime, endpoint } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const { events } = useLive()
const paused = ref(false)
const frozen = ref<LiveEvent[]>([])
const query = ref('')
const action = ref('all')
const proto = ref('all')
const selected = ref<LiveEvent | null>(null)
watch(paused, (value) => {
frozen.value = value ? events.value : []
})
const source = computed(() => (paused.value ? frozen.value : events.value))
const pending = computed(() =>
paused.value ? Math.max(0, events.value.length - frozen.value.length) : 0,
)
const filtered = computed(() => {
const q = query.value.trim().toLowerCase()
return source.value.filter((e) => {
if (action.value !== 'all') {
if (action.value === 'log' || action.value === 'error') {
if (e.kind !== action.value) return false
} else if (e.kind !== 'action' || e.action !== action.value) {
return false
}
}
if (proto.value !== 'all' && e.proto !== proto.value) return false
if (!q) return true
return (
e.srcIP.toLowerCase().includes(q) ||
e.dstIP.toLowerCase().includes(q) ||
(e.host ?? '').toLowerCase().includes(q) ||
(e.rule ?? '').toLowerCase().includes(q)
)
})
})
const actionOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'block', label: t('action.block') },
{ value: 'drop', label: t('action.drop') },
{ value: 'allow', label: t('action.allow') },
{ value: 'modify', label: t('action.modify') },
{ value: 'log', label: t('action.log') },
{ value: 'error', label: t('action.error') },
])
const protoOptions = computed(() => [
{ value: 'all', label: t('common.all') },
{ value: 'tcp', label: 'TCP' },
{ value: 'udp', label: 'UDP' },
])
const propEntries = computed(() => Object.entries(selected.value?.props ?? {}))
async function copyDetail() {
if (!selected.value) return
try {
await navigator.clipboard.writeText(JSON.stringify(selected.value, null, 2))
toast.success(t('common.copied'))
} catch {
toast.error(t('common.error'))
}
}
function clear() {
clearEvents()
frozen.value = []
}
</script>
<template>
<div>
<PageHeader :title="t('events.title')" :description="t('events.subtitle')">
<template #actions>
<Button variant="outline" size="sm" @click="paused = !paused">
<Play v-if="paused" class="size-3.5" />
<Pause v-else class="size-3.5" />
<span class="hidden sm:inline">{{ paused ? t('events.resume') : t('events.pause') }}</span>
</Button>
<Button variant="outline" size="sm" @click="clear">
<Trash2 class="size-3.5" />
<span class="hidden sm:inline">{{ t('events.clear') }}</span>
</Button>
</template>
</PageHeader>
<div class="mb-3 flex flex-col gap-2 sm:flex-row">
<div class="relative flex-1">
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
<Input
v-model="query"
:placeholder="t('events.searchPlaceholder')"
class="pl-9"
type="search"
/>
</div>
<div class="grid grid-cols-2 gap-2 sm:flex">
<Select
v-model="action"
:options="actionOptions"
:aria-label="t('events.filterAction')"
class="sm:w-32"
/>
<Select
v-model="proto"
:options="protoOptions"
:aria-label="t('events.filterProto')"
class="sm:w-28"
/>
</div>
</div>
<div
v-if="paused"
class="text-muted-foreground mb-2 flex items-center justify-between gap-2 text-xs"
>
<Badge variant="drop">{{ t('events.paused') }}</Badge>
<span v-if="pending > 0">{{ t('events.buffered', { n: pending }) }}</span>
</div>
<Card class="px-1.5 py-1.5 sm:px-2.5">
<div v-if="filtered.length" class="flex flex-col">
<EventItem
v-for="event in filtered"
:key="event.seq"
:event="event"
@select="selected = $event"
/>
</div>
<p v-else class="text-muted-foreground py-12 text-center text-sm">
{{ source.length ? t('events.emptyFiltered') : t('events.empty') }}
</p>
</Card>
<Modal
:open="selected !== null"
:title="t('events.detail')"
@update:open="!$event && (selected = null)"
>
<div v-if="selected" class="flex flex-col gap-3 text-sm">
<div class="flex flex-wrap items-center gap-2">
<ActionBadge :event="selected" />
<Badge :variant="selected.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ selected.proto }}
</Badge>
<span class="text-muted-foreground text-xs">{{ dateTime(selected.time) }}</span>
</div>
<dl class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs sm:text-sm">
<dt class="text-muted-foreground">{{ t('events.stream') }}</dt>
<dd class="font-mono break-all">{{ selected.id }}</dd>
<dt class="text-muted-foreground">{{ t('events.source') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.srcIP, selected.srcPort) }}</dd>
<dt class="text-muted-foreground">{{ t('events.destination') }}</dt>
<dd class="font-mono break-all">{{ endpoint(selected.dstIP, selected.dstPort) }}</dd>
<template v-if="selected.host">
<dt class="text-muted-foreground">{{ t('events.host') }}</dt>
<dd class="font-mono break-all">{{ selected.host }}</dd>
</template>
<template v-if="selected.rule">
<dt class="text-muted-foreground">{{ t('events.rule') }}</dt>
<dd class="break-all">{{ selected.rule }}</dd>
</template>
<template v-if="selected.message">
<dt class="text-muted-foreground">{{ t('common.error') }}</dt>
<dd class="text-destructive break-all">{{ selected.message }}</dd>
</template>
</dl>
<div>
<p class="text-muted-foreground mb-1.5 text-xs font-medium">{{ t('events.props') }}</p>
<div v-if="propEntries.length" class="flex flex-col gap-2">
<div v-for="[name, value] in propEntries" :key="name" class="rounded-lg border">
<div class="border-b px-2.5 py-1.5 text-xs font-medium">{{ name }}</div>
<pre
class="overflow-x-auto px-2.5 py-2 font-mono text-[11px] leading-relaxed"
>{{ JSON.stringify(value, null, 2) }}</pre>
</div>
</div>
<p v-else class="text-muted-foreground text-xs">{{ t('events.noProps') }}</p>
</div>
</div>
<template #footer>
<Button variant="outline" @click="copyDetail">
<Copy class="size-4" />
{{ t('events.copyJson') }}
</Button>
<Button @click="selected = null">{{ t('common.close') }}</Button>
</template>
</Modal>
</div>
</template>

Some files were not shown because too many files have changed in this diff Show More