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>
This commit is contained in:
@@ -30,6 +30,35 @@ jobs:
|
||||
with:
|
||||
install-go: false
|
||||
|
||||
web-ui:
|
||||
name: Web UI
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: web/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Check that the embedded web/dist is up to date
|
||||
run: |
|
||||
git diff --exit-code -- web/dist ||
|
||||
(echo "::error::web/dist is stale, run 'make web' and commit the result" && exit 1)
|
||||
|
||||
tests:
|
||||
name: Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -24,6 +24,17 @@ jobs:
|
||||
with:
|
||||
go-version: "1.22"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: web/frontend/package-lock.json
|
||||
|
||||
- name: Build web UI
|
||||
run: npm ci && npm run build
|
||||
working-directory: web/frontend
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GOOS: ${{ matrix.goos }}
|
||||
@@ -31,7 +42,8 @@ jobs:
|
||||
CGO_ENABLED: 0
|
||||
run: |
|
||||
mkdir -p build
|
||||
go build -o build/OpenGFW-${GOOS}-${GOARCH} -ldflags "-s -w" .
|
||||
go build -o build/OpenGFW-${GOOS}-${GOARCH} \
|
||||
-ldflags "-s -w -X github.com/apernet/OpenGFW/cmd.appVersion=${GITHUB_REF_NAME} -X github.com/apernet/OpenGFW/cmd.appCommit=${GITHUB_SHA::7}" .
|
||||
|
||||
- name: Upload
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
@@ -208,3 +208,9 @@ $RECYCLE.BIN/
|
||||
|
||||
# Internal tools not ready for public use yet
|
||||
tools/flowseq/
|
||||
|
||||
### Web UI ###
|
||||
# The built UI in web/dist IS committed, since it is embedded into the binary.
|
||||
web/frontend/node_modules/
|
||||
web/frontend/*.tsbuildinfo
|
||||
web/frontend/.vite/
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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
|
||||
+22
-1
@@ -29,7 +29,28 @@ Telegram グループ: https://t.me/OpGFW
|
||||
- ルールのホットリロード (`SIGHUP` を送信してリロード)
|
||||
- 柔軟なアナライザ&モディファイアフレームワーク
|
||||
- 拡張可能な IO 実装 (今のところ NFQueue のみ)
|
||||
- [WIP] ウェブ UI
|
||||
- リアルタイムのトラフィックダッシュボードとルールエディタを備えたウェブ UI
|
||||
|
||||
## ウェブ UI
|
||||
|
||||
OpenGFW にはオプションのウェブダッシュボードが同梱されています。リアルタイムのトラフィック統計、
|
||||
アナライザのプロパティを含むイベントフィード、そしてルールを検証してホットリロードできるルールエディタが利用できます。
|
||||
スマートフォンでもデスクトップでも使えるレスポンシブ対応です。
|
||||
|
||||
設定ファイルで有効にします:
|
||||
|
||||
```yaml
|
||||
web:
|
||||
enabled: true
|
||||
listen: :8080
|
||||
secret: パスワード # 空の場合は起動時にランダム生成されログに出力されます
|
||||
# cert: /path/to/fullchain.pem
|
||||
# key: /path/to/privkey.pem
|
||||
```
|
||||
|
||||
UI はバイナリに埋め込まれているため、追加のファイルを配置する必要はありません。
|
||||
パスワードを知る人はルールを変更できるため、信頼できるネットワークにのみ公開してください。
|
||||
開発方法は [web/README.md](web/README.md) を参照してください。
|
||||
|
||||
## ユースケース
|
||||
|
||||
|
||||
@@ -33,7 +33,28 @@ 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.
|
||||
|
||||
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
|
||||
|
||||
|
||||
+20
-1
@@ -29,7 +29,26 @@ Telegram 群组: https://t.me/OpGFW
|
||||
- 规则可以热重载 (发送 `SIGHUP` 信号)
|
||||
- 灵活的协议解析和修改框架
|
||||
- 可扩展的 IO 实现 (目前只有 NFQueue)
|
||||
- [开发中] Web UI
|
||||
- Web UI,包含实时流量面板与规则编辑器
|
||||
|
||||
## Web UI
|
||||
|
||||
OpenGFW 内置了一个可选的 Web 控制面板:实时流量统计、带解析器属性的实时事件流,以及可以校验并热加载规则的规则编辑器。
|
||||
界面同时适配手机和桌面浏览器。
|
||||
|
||||
在配置文件中启用:
|
||||
|
||||
```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
@@ -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),
|
||||
|
||||
+291
@@ -0,0 +1,291 @@
|
||||
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/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) },
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
+6
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# 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** — visual rule editor and a raw YAML editor, both validated by the engine
|
||||
itself; saving writes the rule file and hot reloads the running engine
|
||||
- **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.
|
||||
|
||||
## 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` | `/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 |
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type rulesRequest struct {
|
||||
Raw string `json:"raw"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
type rulesResponse struct {
|
||||
Path string `json:"path"`
|
||||
Raw string `json:"raw"`
|
||||
Rules []Rule `json:"rules"`
|
||||
}
|
||||
|
||||
// resolveRaw turns a request into rule file content.
|
||||
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
|
||||
if req.Rules != nil {
|
||||
return s.config.Rules.Marshal(req.Rules)
|
||||
}
|
||||
return req.Raw, nil
|
||||
}
|
||||
|
||||
// GET/PUT /api/v1/rules
|
||||
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
|
||||
return
|
||||
}
|
||||
if s.config.Rules == nil {
|
||||
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||
return
|
||||
}
|
||||
if r.Method == http.MethodGet {
|
||||
raw, rules, err := s.config.Rules.Load()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||
return
|
||||
}
|
||||
var req rulesRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
raw, err := s.resolveRaw(req)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
rules, err := s.config.Rules.Apply(raw)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
|
||||
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||
}
|
||||
|
||||
// POST /api/v1/rules/validate
|
||||
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
|
||||
if !methodAllowed(w, r, http.MethodPost) {
|
||||
return
|
||||
}
|
||||
if s.config.Rules == nil {
|
||||
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||
return
|
||||
}
|
||||
var req rulesRequest
|
||||
if !decodeBody(w, r, &req) {
|
||||
return
|
||||
}
|
||||
raw, err := s.resolveRaw(req)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
rules, err := s.config.Rules.Validate(raw)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"valid": false,
|
||||
"error": err.Error(),
|
||||
"raw": raw,
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"valid": true,
|
||||
"rules": rules,
|
||||
"raw": raw,
|
||||
})
|
||||
}
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookieName = "opengfw_session"
|
||||
sessionTTL = 7 * 24 * time.Hour
|
||||
maxLoginFailures = 8
|
||||
loginBanDuration = 5 * time.Minute
|
||||
)
|
||||
|
||||
type authenticator struct {
|
||||
secret string
|
||||
|
||||
mu sync.Mutex
|
||||
sessions map[string]time.Time // token -> expiry
|
||||
failures map[string]*failureRecord
|
||||
}
|
||||
|
||||
type failureRecord struct {
|
||||
count int
|
||||
until time.Time
|
||||
}
|
||||
|
||||
func newAuthenticator(secret string) *authenticator {
|
||||
return &authenticator{
|
||||
secret: secret,
|
||||
sessions: make(map[string]time.Time),
|
||||
failures: make(map[string]*failureRecord),
|
||||
}
|
||||
}
|
||||
|
||||
// RandomSecret generates a secret to be used when the user did not set one.
|
||||
func RandomSecret() string {
|
||||
b := make([]byte, 12)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "opengfw"
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newToken() string {
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// login verifies the password and returns a new session token.
|
||||
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
|
||||
ip := hostOnly(remoteAddr)
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
|
||||
return "", time.Time{}, errTooManyAttempts
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
|
||||
rec, ok := a.failures[ip]
|
||||
if !ok || now.After(rec.until) {
|
||||
rec = &failureRecord{}
|
||||
a.failures[ip] = rec
|
||||
}
|
||||
rec.count++
|
||||
rec.until = now.Add(loginBanDuration)
|
||||
return "", time.Time{}, errBadCredentials
|
||||
}
|
||||
delete(a.failures, ip)
|
||||
token := newToken()
|
||||
if token == "" {
|
||||
return "", time.Time{}, errInternal
|
||||
}
|
||||
expiry := now.Add(sessionTTL)
|
||||
a.sessions[token] = expiry
|
||||
a.gcLocked(now)
|
||||
return token, expiry, nil
|
||||
}
|
||||
|
||||
func (a *authenticator) logout(token string) {
|
||||
if token == "" {
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
delete(a.sessions, token)
|
||||
a.mu.Unlock()
|
||||
}
|
||||
|
||||
func (a *authenticator) valid(token string) bool {
|
||||
if token == "" {
|
||||
return false
|
||||
}
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
expiry, ok := a.sessions[token]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(expiry) {
|
||||
delete(a.sessions, token)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *authenticator) gcLocked(now time.Time) {
|
||||
for t, exp := range a.sessions {
|
||||
if now.After(exp) {
|
||||
delete(a.sessions, t)
|
||||
}
|
||||
}
|
||||
for ip, rec := range a.failures {
|
||||
if now.After(rec.until) {
|
||||
delete(a.failures, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// bearerToken returns the token carried by the Authorization header, if any.
|
||||
func bearerToken(r *http.Request) string {
|
||||
const prefix = "Bearer "
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
|
||||
return h[len(prefix):]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// cookieToken returns the token carried by the session cookie, if any.
|
||||
func cookieToken(r *http.Request) string {
|
||||
c, err := r.Cookie(sessionCookieName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
|
||||
func hostOnly(addr string) string {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
// 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"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"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",
|
||||
},
|
||||
}
|
||||
},
|
||||
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: it validates the YAML shape but
|
||||
// does not compile expressions, which is enough for UI work.
|
||||
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
|
||||
}
|
||||
|
||||
func parse(raw string) ([]web.Rule, error) {
|
||||
var rules []web.Rule
|
||||
if err := yaml.Unmarshal([]byte(raw), &rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i, r := range rules {
|
||||
if r.Name == "" {
|
||||
return nil, fmt.Errorf("rule #%d has no name", i+1)
|
||||
}
|
||||
if r.Expr == "" {
|
||||
return nil, fmt.Errorf("rule %q has no expression", r.Name)
|
||||
}
|
||||
if r.Action == "" && !r.Log {
|
||||
return nil, fmt.Errorf("rule %q must have at least one of action or log", r.Name)
|
||||
}
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as M,d as B,K as L,M as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-D3ZCYstt.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-C9uTg8Ty.js";import{c as C}from"./format-B6sc_rZ4.js";/**
|
||||
* @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"},K={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(O){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",K,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
|
||||
@@ -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,a0 as c}from"./index-D3ZCYstt.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
|
||||
@@ -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-D3ZCYstt.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
|
||||
+1
@@ -0,0 +1 @@
|
||||
import{ax as I,ay as $,m as v,g as p,az as j,af as S,d as C,ad as b,ak as g,aA as w,x as k,aB as P}from"./index-D3ZCYstt.js";function T(e,i){const t=typeof e=="string"&&!i?`${e}Context`:i,o=Symbol(t);return[a=>{const c=I(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>($(o,a),a)]}function D(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[h]=T("ConfigProvider");function _(e){const i=h({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=i.dir)==null?void 0:t.value)||"ltr"})}function K(e,i="reka"){var n;let t;const o=h({useId:void 0});return o.useId?t=o.useId():t=(n=j)==null?void 0:n(),i?`${i}-${t}`:t}function x(){const e=v(),i=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:i}}const A="data-reka-collection-item";function q(e={}){const{key:i="",isProvider:t=!1}=e,o=`${i}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},$(o,n)}else n=I(o);const y=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${A}]`)),m=new Map(f.map((l,E)=>[l,E])),d=Array.from(n.itemMap.value.values()).sort((l,E)=>(m.get(l.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(l=>l.ref.dataset.disabled!=="")},a=C({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return k(s,()=>{n.collectionRef.value=s.value}),()=>g(w,{ref:m,...f},r)}}),c=C({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=x();return b(d=>{if(s.value){const l=P(s.value);n.itemMap.value.set(l,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(l))}}),()=>g(w,{...f,[A]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),R=p(()=>n.itemMap.value.size);return{getItems:y,reactiveItems:M,itemMapSize:R,CollectionSlot:a,CollectionItem:c}}export{_ as a,q as b,T as c,K as d,D as g,h as i,x as u};
|
||||
+26
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
||||
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang--8bF8KCa.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-D3ZCYstt.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
|
||||
* @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};
|
||||
+21
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-D3ZCYstt.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
|
||||
+36
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
@@ -0,0 +1,16 @@
|
||||
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,K as F,M as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a1 as E,a2 as q,g as v,a3 as H,v as K,a4 as U,a5 as A}from"./index-D3ZCYstt.js";import{_ as G}from"./PageHeader.vue_vue_type_script_setup_true_lang-CDyKzgiP.js";import{_ as x,a as h,c as b,b as J}from"./CardTitle.vue_vue_type_script_setup_true_lang-BcE23HB-.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-C15l-n0X.js";import"./Collection-D-pf6tmY.js";/**
|
||||
* @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=K(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:U},{value:"dark",label:e("settings.themeDark"),icon:A},{value:"system",label:e("settings.themeSystem"),icon:Y}]),Q=[{value:"en",label:"English"},{value:"zh",label:"中文"}],j=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),I=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function O(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(G,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:Q,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(J),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(I.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:O},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-D3ZCYstt.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
|
||||
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
||||
function o(t){if(!Number.isFinite(t))return"0";if(t<1e3)return String(t);const e=["k","M","G","T"];let n=t,r=-1;for(;n>=1e3&&r<e.length-1;)n/=1e3,r++;return`${n>=100?Math.round(n):n.toFixed(1).replace(/\.0$/,"")}${e[r]}`}function i(t,e){return e?Math.round(t/e*100):0}function u(t){if(t<60)return`${Math.max(0,Math.floor(t))}s`;const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`}function a(t){return new Date(t).toLocaleTimeString(void 0,{hour12:!1})}function f(t){const e=new Date(t);return`${e.toLocaleDateString()} ${e.toLocaleTimeString(void 0,{hour12:!1})}`}function c(t,e){return t.includes(":")?`[${t}]:${e}`:`${t}:${e}`}export{f as a,o as c,u as d,c as e,i as p,a as t};
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+106
File diff suppressed because one or more lines are too long
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||
<path
|
||||
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||
fill="none"
|
||||
stroke="#a1a1aa"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
Vendored
+33
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||
/>
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>OpenGFW</title>
|
||||
<script>
|
||||
// Apply the stored theme before first paint to avoid a flash.
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
<script type="module" crossorigin src="/assets/index-D3ZCYstt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CNsDVyYx.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
package web
|
||||
|
||||
import "embed"
|
||||
|
||||
// distFS holds the compiled web UI. Run `make web` (or `npm run build` inside
|
||||
// web/frontend) to refresh it; the checked-in placeholder simply tells the user
|
||||
// that the UI has not been built yet.
|
||||
//
|
||||
//go:embed all:dist
|
||||
var distFS embed.FS
|
||||
@@ -0,0 +1,32 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||
/>
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||
<title>OpenGFW</title>
|
||||
<script>
|
||||
// Apply the stored theme before first paint to avoid a flash.
|
||||
;(function () {
|
||||
try {
|
||||
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||
var dark =
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "opengfw-web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "Web UI for OpenGFW",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.469.0",
|
||||
"reka-ui": "^2.5.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/node": "^22.10.5",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "~5.7.3",
|
||||
"vite": "^6.0.7",
|
||||
"vue-tsc": "^2.2.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||
<path
|
||||
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||
fill="none"
|
||||
stroke="#a1a1aa"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router'
|
||||
|
||||
import Toaster from '@/components/Toaster.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<Toaster />
|
||||
</template>
|
||||
@@ -0,0 +1,208 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
|
||||
/* Verdict colors, shared by badges, charts and the live feed. */
|
||||
--allow: oklch(0.63 0.14 155);
|
||||
--block: oklch(0.58 0.22 27);
|
||||
--drop: oklch(0.72 0.16 70);
|
||||
--modify: oklch(0.6 0.16 255);
|
||||
--log: oklch(0.62 0.11 215);
|
||||
--tcp: oklch(0.62 0.15 265);
|
||||
--udp: oklch(0.68 0.13 190);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.19 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--destructive-foreground: oklch(0.985 0 0);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
|
||||
--allow: oklch(0.72 0.16 155);
|
||||
--block: oklch(0.68 0.2 22);
|
||||
--drop: oklch(0.79 0.15 75);
|
||||
--modify: oklch(0.7 0.15 255);
|
||||
--log: oklch(0.72 0.11 215);
|
||||
--tcp: oklch(0.7 0.15 265);
|
||||
--udp: oklch(0.76 0.12 190);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
--color-allow: var(--allow);
|
||||
--color-block: var(--block);
|
||||
--color-drop: var(--drop);
|
||||
--color-modify: var(--modify);
|
||||
--color-log: var(--log);
|
||||
--color-tcp: var(--tcp);
|
||||
--color-udp: var(--udp);
|
||||
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
|
||||
--animate-fade-in: fade-in 0.18s ease-out;
|
||||
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
html {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family:
|
||||
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
|
||||
background-clip: content-box;
|
||||
}
|
||||
}
|
||||
|
||||
@utility safe-bottom {
|
||||
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
||||
}
|
||||
|
||||
@utility safe-top {
|
||||
padding-top: max(env(safe-area-inset-top), 0px);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { Badge } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import type { LiveEvent } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ event: LiveEvent }>()
|
||||
|
||||
const variant = computed(() => {
|
||||
if (props.event.kind === 'error') return 'block'
|
||||
if (props.event.kind === 'log') return 'log'
|
||||
switch (props.event.action) {
|
||||
case 'allow':
|
||||
return 'allow'
|
||||
case 'block':
|
||||
return 'block'
|
||||
case 'drop':
|
||||
return 'drop'
|
||||
case 'modify':
|
||||
return 'modify'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
})
|
||||
|
||||
const label = computed(() => {
|
||||
if (props.event.kind === 'error') return t('action.error')
|
||||
if (props.event.kind === 'log') return t('action.log')
|
||||
return t(`action.${props.event.action ?? 'maybe'}`)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
|
||||
</template>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||
|
||||
import StatusPill from '@/components/StatusPill.vue'
|
||||
import { startLive, stopLive } from '@/composables/useLive'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const route = useRoute()
|
||||
useTheme()
|
||||
|
||||
const items = computed(() => [
|
||||
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
||||
{ to: '/events', label: t('nav.events'), icon: Radio },
|
||||
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
|
||||
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
|
||||
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
|
||||
])
|
||||
|
||||
const title = computed(() => {
|
||||
const item = items.value.find((i) => i.to === route.path)
|
||||
return item?.label ?? t('app.name')
|
||||
})
|
||||
|
||||
onMounted(startLive)
|
||||
onUnmounted(stopLive)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background min-h-dvh">
|
||||
<!-- Desktop sidebar -->
|
||||
<aside
|
||||
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
|
||||
>
|
||||
<div class="flex items-center gap-2 px-2 pb-4">
|
||||
<div
|
||||
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
|
||||
>
|
||||
G
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
|
||||
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav class="flex flex-1 flex-col gap-0.5">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||
route.path === item.to
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||
)
|
||||
"
|
||||
>
|
||||
<component :is="item.icon" class="size-4 shrink-0" />
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="px-2 pt-3">
|
||||
<StatusPill />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
|
||||
<!-- Mobile top bar -->
|
||||
<header
|
||||
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div class="flex h-12 items-center justify-between gap-3 px-4">
|
||||
<span class="truncate text-sm font-semibold">{{ title }}</span>
|
||||
<StatusPill />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
|
||||
<RouterView v-slot="{ Component }">
|
||||
<component :is="Component" class="animate-fade-in" />
|
||||
</RouterView>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom navigation -->
|
||||
<nav
|
||||
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
|
||||
>
|
||||
<div class="grid grid-cols-5">
|
||||
<RouterLink
|
||||
v-for="item in items"
|
||||
:key="item.to"
|
||||
:to="item.to"
|
||||
:class="
|
||||
cn(
|
||||
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
|
||||
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
|
||||
)
|
||||
"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
:class="cn('size-5', route.path === item.to && 'text-primary')"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { compact } from '@/lib/format'
|
||||
import type { NameCount } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
|
||||
{ tone: 'primary' },
|
||||
)
|
||||
|
||||
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
|
||||
|
||||
const bars: Record<string, string> = {
|
||||
primary: 'bg-primary/15',
|
||||
block: 'bg-block/15',
|
||||
allow: 'bg-allow/15',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.items.length" class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="item in props.items"
|
||||
:key="item.name"
|
||||
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
|
||||
>
|
||||
<div
|
||||
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
|
||||
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
|
||||
/>
|
||||
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
|
||||
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
|
||||
compact(item.count)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-muted-foreground py-6 text-center text-xs">
|
||||
{{ props.empty ?? '—' }}
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowRight } from 'lucide-vue-next'
|
||||
|
||||
import ActionBadge from '@/components/ActionBadge.vue'
|
||||
import { Badge } from '@/components/ui'
|
||||
import { endpoint, time } from '@/lib/format'
|
||||
import type { LiveEvent } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ event: LiveEvent }>()
|
||||
defineEmits<{ select: [event: LiveEvent] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
|
||||
@click="$emit('select', props.event)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
|
||||
{{ time(props.event.time) }}
|
||||
</span>
|
||||
<ActionBadge :event="props.event" />
|
||||
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
|
||||
{{ props.event.proto }}
|
||||
</Badge>
|
||||
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
|
||||
{{ props.event.rule }}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
|
||||
<span class="text-muted-foreground truncate">
|
||||
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
|
||||
</span>
|
||||
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
|
||||
<span class="truncate">
|
||||
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
|
||||
{{ props.event.rule }}
|
||||
</Badge>
|
||||
</button>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ title: string; description?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
|
||||
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
|
||||
{{ props.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { Component } from 'vue'
|
||||
|
||||
import { Card } from '@/components/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
value: string
|
||||
hint?: string
|
||||
icon?: Component
|
||||
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
|
||||
}>()
|
||||
|
||||
const tones: Record<string, string> = {
|
||||
default: 'text-muted-foreground',
|
||||
allow: 'text-allow',
|
||||
block: 'text-block',
|
||||
drop: 'text-drop',
|
||||
modify: 'text-modify',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card class="p-3.5 sm:p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
|
||||
<component
|
||||
:is="props.icon"
|
||||
v-if="props.icon"
|
||||
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
|
||||
/>
|
||||
</div>
|
||||
<div class="mt-1.5 flex items-baseline gap-1.5">
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
|
||||
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
|
||||
)
|
||||
"
|
||||
>{{ props.value }}</span
|
||||
>
|
||||
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
|
||||
props.hint
|
||||
}}</span>
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useLive } from '@/composables/useLive'
|
||||
import { t } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const { status } = useLive()
|
||||
|
||||
const label = computed(() => {
|
||||
switch (status.value) {
|
||||
case 'open':
|
||||
return t('common.live')
|
||||
case 'connecting':
|
||||
return t('common.connecting')
|
||||
default:
|
||||
return t('common.offline')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||
status === 'open'
|
||||
? 'border-allow/30 bg-allow/10 text-allow'
|
||||
: status === 'connecting'
|
||||
? 'border-drop/30 bg-drop/10 text-drop'
|
||||
: 'border-block/30 bg-block/10 text-block',
|
||||
)
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
cn(
|
||||
'size-1.5 rounded-full',
|
||||
status === 'open'
|
||||
? 'bg-allow animate-pulse-dot'
|
||||
: status === 'connecting'
|
||||
? 'bg-drop animate-pulse-dot'
|
||||
: 'bg-block',
|
||||
)
|
||||
"
|
||||
/>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
|
||||
|
||||
import { dismiss, useToast } from '@/composables/useToast'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const { toasts } = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
|
||||
>
|
||||
<TransitionGroup
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
|
||||
leave-active-class="transition duration-150 ease-in absolute"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-for="item in toasts"
|
||||
:key="item.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
|
||||
item.variant === 'error' && 'border-block/40',
|
||||
item.variant === 'success' && 'border-allow/40',
|
||||
)
|
||||
"
|
||||
>
|
||||
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
|
||||
<TriangleAlert
|
||||
v-else-if="item.variant === 'error'"
|
||||
class="text-block mt-0.5 size-4 shrink-0"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium">{{ item.title }}</p>
|
||||
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground -m-1 p-1"
|
||||
@click="dismiss(item.id)"
|
||||
>
|
||||
<X class="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,168 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { t } from '@/i18n'
|
||||
import { compact, time } from '@/lib/format'
|
||||
import type { Bucket } from '@/lib/types'
|
||||
|
||||
const props = defineProps<{ buckets: Bucket[] }>()
|
||||
|
||||
const W = 600
|
||||
const H = 160
|
||||
const PAD_TOP = 8
|
||||
const PAD_BOTTOM = 16
|
||||
|
||||
const hover = ref<number | null>(null)
|
||||
|
||||
const points = computed(() => props.buckets ?? [])
|
||||
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
|
||||
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
|
||||
|
||||
function x(i: number): number {
|
||||
const n = points.value.length
|
||||
return n <= 1 ? 0 : (i / (n - 1)) * W
|
||||
}
|
||||
|
||||
function y(value: number): number {
|
||||
const usable = H - PAD_TOP - PAD_BOTTOM
|
||||
return PAD_TOP + usable - (value / max.value) * usable
|
||||
}
|
||||
|
||||
/** Builds a closed area path for a series of stacked values. */
|
||||
function area(values: number[]): string {
|
||||
if (values.length === 0) return ''
|
||||
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||
const base = `L${W},${y(0)} L0,${y(0)} Z`
|
||||
return top.join(' ') + ' ' + base
|
||||
}
|
||||
|
||||
function line(values: number[]): string {
|
||||
return values
|
||||
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
|
||||
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
|
||||
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
|
||||
|
||||
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
|
||||
|
||||
function onMove(event: PointerEvent) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const ratio = (event.clientX - rect.left) / rect.width
|
||||
const index = Math.round(ratio * (points.value.length - 1))
|
||||
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
|
||||
}
|
||||
|
||||
const legend = computed(() => {
|
||||
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
|
||||
points.value.reduce((acc, b) => acc + b[key], 0)
|
||||
return [
|
||||
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
|
||||
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
|
||||
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
|
||||
]
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
class="relative touch-pan-y"
|
||||
@pointermove="onMove"
|
||||
@pointerdown="onMove"
|
||||
@pointerleave="hover = null"
|
||||
>
|
||||
<svg
|
||||
:viewBox="`0 0 ${W} ${H}`"
|
||||
preserveAspectRatio="none"
|
||||
class="h-36 w-full sm:h-44"
|
||||
role="img"
|
||||
>
|
||||
<line
|
||||
v-for="frac in [0, 0.5, 1]"
|
||||
:key="frac"
|
||||
:x1="0"
|
||||
:x2="W"
|
||||
:y1="y(max * frac)"
|
||||
:y2="y(max * frac)"
|
||||
class="stroke-border"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"
|
||||
stroke-dasharray="3 4"
|
||||
/>
|
||||
|
||||
<path :d="totalPath" class="fill-udp/25" />
|
||||
<path :d="tcpPath" class="fill-tcp/45" />
|
||||
<path
|
||||
:d="blockedPath"
|
||||
class="stroke-block"
|
||||
fill="none"
|
||||
stroke-width="1.75"
|
||||
stroke-linejoin="round"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
|
||||
<template v-if="hover !== null">
|
||||
<line
|
||||
:x1="x(hover)"
|
||||
:x2="x(hover)"
|
||||
:y1="PAD_TOP"
|
||||
:y2="y(0)"
|
||||
class="stroke-foreground/40"
|
||||
stroke-width="1"
|
||||
vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
|
||||
>
|
||||
{{ compact(max) }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="active"
|
||||
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
|
||||
:style="{
|
||||
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
|
||||
transform:
|
||||
hover! / Math.max(1, points.length - 1) > 0.6
|
||||
? 'translateX(-105%)'
|
||||
: 'translateX(8px)',
|
||||
}"
|
||||
>
|
||||
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
|
||||
<span class="ml-auto font-medium">{{ active.tcp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
|
||||
<span class="ml-auto font-medium">{{ active.udp }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 tabular-nums">
|
||||
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
|
||||
<span class="ml-auto font-medium">{{ active.blocked }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!hasData"
|
||||
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
|
||||
>
|
||||
{{ t('dashboard.noTraffic') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
|
||||
<span :class="['size-2 rounded-full', item.color]" />
|
||||
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||
outline: 'text-foreground',
|
||||
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||
allow: 'border-allow/25 bg-allow/12 text-allow',
|
||||
block: 'border-block/25 bg-block/12 text-block',
|
||||
drop: 'border-drop/25 bg-drop/12 text-drop',
|
||||
modify: 'border-modify/25 bg-modify/12 text-modify',
|
||||
log: 'border-log/25 bg-log/12 text-log',
|
||||
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
|
||||
udp: 'border-udp/25 bg-udp/12 text-udp',
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: 'default' },
|
||||
},
|
||||
)
|
||||
|
||||
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
|
||||
export const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
|
||||
outline:
|
||||
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9',
|
||||
'icon-sm': 'size-8 rounded-md',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<
|
||||
PrimitiveProps & {
|
||||
variant?: ButtonVariants['variant']
|
||||
size?: ButtonVariants['size']
|
||||
class?: string
|
||||
}
|
||||
>(),
|
||||
{ as: 'button' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Primitive
|
||||
:as="props.as"
|
||||
:as-child="props.asChild"
|
||||
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
|
||||
>
|
||||
<slot />
|
||||
</Primitive>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
cn(
|
||||
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
|
||||
<slot />
|
||||
</p>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
|
||||
<slot />
|
||||
</h3>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string | number }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { Label } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; for?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Label
|
||||
:for="props.for"
|
||||
:class="
|
||||
cn(
|
||||
'flex items-center gap-2 text-sm leading-none font-medium select-none',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</Label>
|
||||
</template>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { X } from 'lucide-vue-next'
|
||||
import {
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogRoot,
|
||||
DialogTitle,
|
||||
} from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* A dialog that turns into a bottom drawer on small screens, which is much
|
||||
* easier to reach with a thumb than a centered modal.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
description?: string
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
|
||||
<DialogPortal>
|
||||
<DialogOverlay
|
||||
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
|
||||
/>
|
||||
<DialogContent
|
||||
:class="
|
||||
cn(
|
||||
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
|
||||
// Mobile: bottom drawer
|
||||
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
|
||||
// Desktop: centered dialog
|
||||
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<div
|
||||
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
|
||||
/>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex flex-col gap-1">
|
||||
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
|
||||
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
|
||||
{{ props.description }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
<DialogClose
|
||||
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
|
||||
>
|
||||
<X class="size-4" />
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||
<slot />
|
||||
</div>
|
||||
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</DialogPortal>
|
||||
</DialogRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import {
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectPortal,
|
||||
SelectRoot,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectViewport,
|
||||
} from 'reka-ui'
|
||||
|
||||
import type { SelectOption } from '@/lib/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SelectOption[]
|
||||
placeholder?: string
|
||||
ariaLabel?: string
|
||||
class?: string
|
||||
}>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SelectRoot
|
||||
:model-value="props.modelValue"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<SelectTrigger
|
||||
:aria-label="props.ariaLabel"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
|
||||
<ChevronDown class="size-4 shrink-0 opacity-50" />
|
||||
</SelectTrigger>
|
||||
<SelectPortal>
|
||||
<SelectContent
|
||||
position="popper"
|
||||
:side-offset="4"
|
||||
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
|
||||
>
|
||||
<SelectViewport class="p-1">
|
||||
<SelectItem
|
||||
v-for="option in props.options"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||
>
|
||||
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||
<SelectItemIndicator class="absolute right-2 flex items-center">
|
||||
<Check class="size-4" />
|
||||
</SelectItemIndicator>
|
||||
</SelectItem>
|
||||
</SelectViewport>
|
||||
</SelectContent>
|
||||
</SelectPortal>
|
||||
</SelectRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
|
||||
{ orientation: 'horizontal' },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
role="separator"
|
||||
:class="
|
||||
cn(
|
||||
'bg-border shrink-0',
|
||||
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
|
||||
</template>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SwitchRoot
|
||||
:id="props.id"
|
||||
:model-value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<SwitchThumb
|
||||
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot
|
||||
:model-value="props.modelValue"
|
||||
:class="cn('flex flex-col gap-3', props.class)"
|
||||
@update:model-value="emit('update:modelValue', String($event))"
|
||||
>
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsContent } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; value: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
|
||||
<slot />
|
||||
</TabsContent>
|
||||
</template>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
:class="
|
||||
cn(
|
||||
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger } from 'reka-ui'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; value: string }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
:value="props.value"
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
|
||||
'[&_svg]:size-3.5',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:value="props.modelValue"
|
||||
:class="
|
||||
cn(
|
||||
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||
'placeholder:text-muted-foreground field-sizing-content',
|
||||
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
props.class,
|
||||
)
|
||||
"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
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 TabsContent } from './TabsContent.vue'
|
||||
export { default as TabsList } from './TabsList.vue'
|
||||
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||
export { default as Textarea } from './Textarea.vue'
|
||||
@@ -0,0 +1,105 @@
|
||||
import { computed, ref, shallowRef } from 'vue'
|
||||
|
||||
import { api, auth, liveUrl } from '@/lib/api'
|
||||
import type { LiveEvent, Metrics } from '@/lib/types'
|
||||
|
||||
const MAX_EVENTS = 500
|
||||
|
||||
export type LiveStatus = 'closed' | 'connecting' | 'open'
|
||||
|
||||
const metrics = ref<Metrics | null>(null)
|
||||
const events = shallowRef<LiveEvent[]>([])
|
||||
const status = ref<LiveStatus>('closed')
|
||||
|
||||
let source: EventSource | null = null
|
||||
let retryTimer: number | undefined
|
||||
let retryDelay = 1000
|
||||
|
||||
function pushEvent(event: LiveEvent) {
|
||||
const next = [event, ...events.value]
|
||||
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
|
||||
events.value = next
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
try {
|
||||
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
|
||||
metrics.value = m
|
||||
if (events.value.length === 0) events.value = e.events ?? []
|
||||
} catch {
|
||||
// The SSE connection will report the problem, no need to duplicate it.
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (source || !auth.authenticated) return
|
||||
status.value = 'connecting'
|
||||
const es = new EventSource(liveUrl)
|
||||
source = es
|
||||
|
||||
es.addEventListener('open', () => {
|
||||
status.value = 'open'
|
||||
retryDelay = 1000
|
||||
})
|
||||
|
||||
es.addEventListener('metrics', (ev) => {
|
||||
try {
|
||||
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
|
||||
status.value = 'open'
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('event', (ev) => {
|
||||
try {
|
||||
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
|
||||
} catch {
|
||||
/* ignore malformed frame */
|
||||
}
|
||||
})
|
||||
|
||||
es.addEventListener('error', () => {
|
||||
es.close()
|
||||
if (source === es) source = null
|
||||
status.value = 'closed'
|
||||
// The session may have expired; a plain fetch tells us for sure.
|
||||
window.clearTimeout(retryTimer)
|
||||
retryTimer = window.setTimeout(() => {
|
||||
if (auth.authenticated) connect()
|
||||
}, retryDelay)
|
||||
retryDelay = Math.min(retryDelay * 2, 15000)
|
||||
})
|
||||
}
|
||||
|
||||
export function startLive() {
|
||||
if (!auth.authenticated) return
|
||||
void seed()
|
||||
connect()
|
||||
}
|
||||
|
||||
export function stopLive() {
|
||||
window.clearTimeout(retryTimer)
|
||||
source?.close()
|
||||
source = null
|
||||
status.value = 'closed'
|
||||
metrics.value = null
|
||||
events.value = []
|
||||
}
|
||||
|
||||
export function clearEvents() {
|
||||
events.value = []
|
||||
}
|
||||
|
||||
export function useLive() {
|
||||
return {
|
||||
metrics,
|
||||
events,
|
||||
status,
|
||||
connected: computed(() => status.value === 'open'),
|
||||
startLive,
|
||||
stopLive,
|
||||
clearEvents,
|
||||
refresh: seed,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
export type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
const THEME_KEY = 'opengfw.theme'
|
||||
const stored = localStorage.getItem(THEME_KEY)
|
||||
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
|
||||
function resolve(value: Theme): boolean {
|
||||
return value === 'dark' || (value === 'system' && media.matches)
|
||||
}
|
||||
|
||||
const isDark = ref(resolve(theme.value))
|
||||
|
||||
function apply() {
|
||||
isDark.value = resolve(theme.value)
|
||||
document.documentElement.classList.toggle('dark', isDark.value)
|
||||
document
|
||||
.querySelector('meta[name="theme-color"]')
|
||||
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
|
||||
}
|
||||
|
||||
watch(theme, (value) => {
|
||||
localStorage.setItem(THEME_KEY, value)
|
||||
apply()
|
||||
})
|
||||
|
||||
media.addEventListener('change', () => {
|
||||
if (theme.value === 'system') apply()
|
||||
})
|
||||
|
||||
apply()
|
||||
|
||||
export function useTheme() {
|
||||
return {
|
||||
theme,
|
||||
isDark: computed(() => isDark.value),
|
||||
setTheme: (value: Theme) => {
|
||||
theme.value = value
|
||||
},
|
||||
toggle: () => {
|
||||
theme.value = isDark.value ? 'light' : 'dark'
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
export type ToastVariant = 'default' | 'success' | 'error'
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
title: string
|
||||
description?: string
|
||||
variant: ToastVariant
|
||||
}
|
||||
|
||||
const toasts = ref<Toast[]>([])
|
||||
let nextId = 1
|
||||
|
||||
function push(title: string, variant: ToastVariant, description?: string) {
|
||||
const id = nextId++
|
||||
toasts.value = [...toasts.value, { id, title, description, variant }]
|
||||
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
|
||||
}
|
||||
|
||||
export function dismiss(id: number) {
|
||||
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||
}
|
||||
|
||||
export const toast = {
|
||||
info: (title: string, description?: string) => push(title, 'default', description),
|
||||
success: (title: string, description?: string) => push(title, 'success', description),
|
||||
error: (title: string, description?: string) => push(title, 'error', description),
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return { toasts, toast, dismiss }
|
||||
}
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<{}, {}, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
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: 'An expression is required',
|
||||
hint: 'Saving writes the rule file and hot reloads the engine.',
|
||||
},
|
||||
analyzers: {
|
||||
title: 'Analyzers',
|
||||
subtitle: 'Protocol analyzers compiled into this build',
|
||||
hits: 'Streams analyzed',
|
||||
modifiers: 'Modifiers',
|
||||
modifiersDesc: 'Available for rules with the modify action',
|
||||
functions: 'Rule functions',
|
||||
functionsDesc: 'Built-in functions usable in expressions',
|
||||
used: 'In use',
|
||||
idle: 'Idle',
|
||||
},
|
||||
settings: {
|
||||
title: 'Settings',
|
||||
subtitle: 'Appearance and instance information',
|
||||
appearance: 'Appearance',
|
||||
theme: 'Theme',
|
||||
themeLight: 'Light',
|
||||
themeDark: 'Dark',
|
||||
themeSystem: 'System',
|
||||
language: 'Language',
|
||||
instance: 'Instance',
|
||||
version: 'Version',
|
||||
platform: 'Platform',
|
||||
goVersion: 'Go version',
|
||||
hostname: 'Hostname',
|
||||
ruleFile: 'Rule file',
|
||||
engineConfig: 'Engine configuration',
|
||||
ioQueueSize: 'NFQueue size',
|
||||
ioLocal: 'Local mode',
|
||||
ioRST: 'Send RST',
|
||||
workers: 'Workers',
|
||||
workerQueueSize: 'Worker queue size',
|
||||
udpMaxStreams: 'Max UDP streams',
|
||||
geoip: 'GeoIP database',
|
||||
geosite: 'GeoSite database',
|
||||
auto: 'Auto',
|
||||
session: 'Session',
|
||||
logout: 'Sign out',
|
||||
docs: 'Documentation',
|
||||
},
|
||||
action: {
|
||||
allow: 'Allow',
|
||||
block: 'Block',
|
||||
drop: 'Drop',
|
||||
modify: 'Modify',
|
||||
maybe: 'Maybe',
|
||||
log: 'Log',
|
||||
error: 'Error',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import en from './en'
|
||||
import zh from './zh'
|
||||
|
||||
export type Lang = 'en' | 'zh'
|
||||
|
||||
const LANG_KEY = 'opengfw.lang'
|
||||
const dictionaries: Record<Lang, Record<string, unknown>> = { en, zh }
|
||||
|
||||
function detect(): Lang {
|
||||
const stored = localStorage.getItem(LANG_KEY)
|
||||
if (stored === 'en' || stored === 'zh') return stored
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
||||
}
|
||||
|
||||
const lang = ref<Lang>(detect())
|
||||
|
||||
function lookup(dict: Record<string, unknown>, key: string): string | undefined {
|
||||
const value = key.split('.').reduce<unknown>((acc, part) => {
|
||||
if (acc && typeof acc === 'object') return (acc as Record<string, unknown>)[part]
|
||||
return undefined
|
||||
}, dict)
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
/** Translates a dotted key, interpolating {placeholders}. */
|
||||
export function t(key: string, params?: Record<string, string | number>): string {
|
||||
const raw = lookup(dictionaries[lang.value], key) ?? lookup(dictionaries.en, key) ?? key
|
||||
if (!params) return raw
|
||||
return raw.replace(/\{(\w+)\}/g, (_, name: string) =>
|
||||
params[name] === undefined ? `{${name}}` : String(params[name]),
|
||||
)
|
||||
}
|
||||
|
||||
export function setLang(value: Lang) {
|
||||
lang.value = value
|
||||
localStorage.setItem(LANG_KEY, value)
|
||||
document.documentElement.lang = value === 'zh' ? 'zh-CN' : 'en'
|
||||
}
|
||||
|
||||
export function useI18n() {
|
||||
return { t, lang: computed(() => lang.value), setLang }
|
||||
}
|
||||
|
||||
document.documentElement.lang = lang.value === 'zh' ? 'zh-CN' : 'en'
|
||||
@@ -0,0 +1,182 @@
|
||||
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: '保存后将写入规则文件并热加载引擎。',
|
||||
},
|
||||
analyzers: {
|
||||
title: '解析器',
|
||||
subtitle: '当前版本内置的协议解析器',
|
||||
hits: '已解析连接',
|
||||
modifiers: '修改器',
|
||||
modifiersDesc: '可用于 modify 动作的规则',
|
||||
functions: '规则函数',
|
||||
functionsDesc: '表达式中可用的内置函数',
|
||||
used: '使用中',
|
||||
idle: '空闲',
|
||||
},
|
||||
settings: {
|
||||
title: '设置',
|
||||
subtitle: '外观与实例信息',
|
||||
appearance: '外观',
|
||||
theme: '主题',
|
||||
themeLight: '浅色',
|
||||
themeDark: '深色',
|
||||
themeSystem: '跟随系统',
|
||||
language: '语言',
|
||||
instance: '实例信息',
|
||||
version: '版本',
|
||||
platform: '平台',
|
||||
goVersion: 'Go 版本',
|
||||
hostname: '主机名',
|
||||
ruleFile: '规则文件',
|
||||
engineConfig: '引擎配置',
|
||||
ioQueueSize: 'NFQueue 队列大小',
|
||||
ioLocal: '本机模式',
|
||||
ioRST: '发送 RST',
|
||||
workers: '工作线程数',
|
||||
workerQueueSize: '工作线程队列大小',
|
||||
udpMaxStreams: 'UDP 最大连接数',
|
||||
geoip: 'GeoIP 数据库',
|
||||
geosite: 'GeoSite 数据库',
|
||||
auto: '自动',
|
||||
session: '会话',
|
||||
logout: '退出登录',
|
||||
docs: '文档',
|
||||
},
|
||||
action: {
|
||||
allow: '放行',
|
||||
block: '拦截',
|
||||
drop: '丢弃',
|
||||
modify: '修改',
|
||||
maybe: '待定',
|
||||
log: '日志',
|
||||
error: '错误',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
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'),
|
||||
|
||||
saveRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
||||
request<RulesResponse>('/rules', { method: 'PUT', body: JSON.stringify(payload) }),
|
||||
|
||||
validateRules: (payload: { raw?: string; rules?: Rule[] }) =>
|
||||
request<ValidateResponse>('/rules/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
}
|
||||
|
||||
export const liveUrl = `${BASE}/live`
|
||||
@@ -0,0 +1,48 @@
|
||||
/** Compact number formatting: 1234 -> 1.2k */
|
||||
export function compact(n: number): string {
|
||||
if (!Number.isFinite(n)) return '0'
|
||||
if (n < 1000) return String(n)
|
||||
const units = ['k', 'M', 'G', 'T']
|
||||
let value = n
|
||||
let unit = -1
|
||||
while (value >= 1000 && unit < units.length - 1) {
|
||||
value /= 1000
|
||||
unit++
|
||||
}
|
||||
return `${value >= 100 ? Math.round(value) : value.toFixed(1).replace(/\.0$/, '')}${units[unit]}`
|
||||
}
|
||||
|
||||
export function percent(part: number, total: number): number {
|
||||
if (!total) return 0
|
||||
return Math.round((part / total) * 100)
|
||||
}
|
||||
|
||||
/** 3725 -> "1h 2m" */
|
||||
export function duration(seconds: number): string {
|
||||
if (seconds < 60) return `${Math.max(0, Math.floor(seconds))}s`
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
if (d > 0) return `${d}d ${h}h`
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
return `${m}m`
|
||||
}
|
||||
|
||||
export function time(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return d.toLocaleTimeString(undefined, { hour12: false })
|
||||
}
|
||||
|
||||
export function dateTime(ts: number): string {
|
||||
const d = new Date(ts)
|
||||
return `${d.toLocaleDateString()} ${d.toLocaleTimeString(undefined, { hour12: false })}`
|
||||
}
|
||||
|
||||
/** Formats an address, wrapping IPv6 in brackets. */
|
||||
export function endpoint(ip: string, port: number): string {
|
||||
return ip.includes(':') ? `[${ip}]:${port}` : `${ip}:${port}`
|
||||
}
|
||||
|
||||
export function truncate(s: string, max = 42): string {
|
||||
return s.length > max ? `${s.slice(0, max - 1)}…` : s
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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 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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import { Loader2, LockKeyhole, Moon, Sun } from 'lucide-vue-next'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { Button, Card, Input, Label } from '@/components/ui'
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { t, useI18n } from '@/i18n'
|
||||
import { api, ApiError } from '@/lib/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const { lang, setLang } = useI18n()
|
||||
const { isDark, toggle } = useTheme()
|
||||
|
||||
const password = ref('')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
async function submit() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await api.login(password.value)
|
||||
const redirect = route.query.redirect
|
||||
await router.replace(typeof redirect === 'string' ? redirect : '/')
|
||||
} catch (e) {
|
||||
error.value = e instanceof ApiError ? e.message : t('login.failed')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-background flex min-h-dvh flex-col">
|
||||
<div class="flex items-center justify-end gap-1 p-3">
|
||||
<Button variant="ghost" size="sm" @click="setLang(lang === 'zh' ? 'en' : 'zh')">
|
||||
{{ lang === 'zh' ? 'EN' : '中文' }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon-sm" :aria-label="t('settings.theme')" @click="toggle">
|
||||
<Moon v-if="isDark" class="size-4" />
|
||||
<Sun v-else class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 items-center justify-center px-4 pb-16">
|
||||
<Card class="animate-slide-up w-full max-w-sm p-6">
|
||||
<div class="mb-6 flex flex-col items-center text-center">
|
||||
<div
|
||||
class="bg-primary text-primary-foreground mb-3 flex size-11 items-center justify-center rounded-xl"
|
||||
>
|
||||
<LockKeyhole class="size-5" />
|
||||
</div>
|
||||
<h1 class="text-lg font-semibold tracking-tight">{{ t('app.name') }}</h1>
|
||||
<p class="text-muted-foreground mt-1 text-sm">{{ t('login.subtitle') }}</p>
|
||||
</div>
|
||||
|
||||
<form class="flex flex-col gap-3" @submit.prevent="submit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="password">{{ t('login.password') }}</Label>
|
||||
<Input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
autofocus
|
||||
:disabled="loading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-destructive text-sm">{{ error }}</p>
|
||||
|
||||
<Button type="submit" class="mt-1 w-full" :disabled="loading || !password">
|
||||
<Loader2 v-if="loading" class="size-4 animate-spin" />
|
||||
{{ t('login.submit') }}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p class="text-muted-foreground mt-4 text-center text-xs">{{ t('login.hint') }}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,451 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
CheckCircle2,
|
||||
FileCode2,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
Label,
|
||||
Modal,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
Textarea,
|
||||
} from '@/components/ui'
|
||||
import { toast } from '@/composables/useToast'
|
||||
import { t } from '@/i18n'
|
||||
import { api, ApiError } from '@/lib/api'
|
||||
import type { MetaInfo, Rule } from '@/lib/types'
|
||||
|
||||
const mode = ref('visual')
|
||||
const rules = ref<Rule[]>([])
|
||||
const raw = ref('')
|
||||
const path = ref('')
|
||||
const meta = ref<MetaInfo | null>(null)
|
||||
|
||||
const loading = ref(true)
|
||||
const busy = ref(false)
|
||||
const dirty = ref(false)
|
||||
const problem = ref('')
|
||||
|
||||
const editing = ref<Rule | null>(null)
|
||||
const editingIndex = ref(-1)
|
||||
const removing = ref<number>(-1)
|
||||
|
||||
const actionOptions = computed(() => [
|
||||
{ value: '', label: t('rules.actionNone') },
|
||||
...(meta.value?.actions ?? []).map((a) => ({ value: a, label: t(`action.${a}`) })),
|
||||
])
|
||||
|
||||
const modifierOptions = computed(() =>
|
||||
(meta.value?.modifiers ?? []).map((m) => ({ value: m, label: m })),
|
||||
)
|
||||
|
||||
const badgeVariant = (action?: string) => {
|
||||
switch (action) {
|
||||
case 'allow':
|
||||
return 'allow'
|
||||
case 'block':
|
||||
return 'block'
|
||||
case 'drop':
|
||||
return 'drop'
|
||||
case 'modify':
|
||||
return 'modify'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [res, m] = await Promise.all([api.rules(), api.meta()])
|
||||
rules.value = res.rules ?? []
|
||||
raw.value = res.raw
|
||||
path.value = res.path
|
||||
meta.value = m
|
||||
} catch (e) {
|
||||
problem.value = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
/** The backend is the single source of truth for YAML <-> rules conversion. */
|
||||
async function switchMode(next: string) {
|
||||
if (next === mode.value || busy.value) return
|
||||
busy.value = true
|
||||
problem.value = ''
|
||||
try {
|
||||
const res = await api.validateRules(next === 'yaml' ? { rules: rules.value } : { raw: raw.value })
|
||||
if (next === 'yaml') {
|
||||
raw.value = res.raw
|
||||
} else {
|
||||
if (!res.valid) {
|
||||
problem.value = res.error ?? t('rules.invalid')
|
||||
return
|
||||
}
|
||||
rules.value = res.rules ?? []
|
||||
}
|
||||
mode.value = next
|
||||
} catch (e) {
|
||||
problem.value = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function payload() {
|
||||
return mode.value === 'yaml' ? { raw: raw.value } : { rules: rules.value }
|
||||
}
|
||||
|
||||
async function validate() {
|
||||
busy.value = true
|
||||
problem.value = ''
|
||||
try {
|
||||
const res = await api.validateRules(payload())
|
||||
if (res.valid) toast.success(t('rules.valid'))
|
||||
else problem.value = res.error ?? t('rules.invalid')
|
||||
} catch (e) {
|
||||
problem.value = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
busy.value = true
|
||||
problem.value = ''
|
||||
try {
|
||||
const res = await api.saveRules(payload())
|
||||
rules.value = res.rules ?? []
|
||||
raw.value = res.raw
|
||||
dirty.value = false
|
||||
toast.success(t('rules.saved'), t('rules.count', { n: rules.value.length }))
|
||||
} catch (e) {
|
||||
problem.value = e instanceof ApiError ? e.message : String(e)
|
||||
toast.error(t('rules.invalid'), problem.value)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
busy.value = true
|
||||
problem.value = ''
|
||||
try {
|
||||
const res = await api.rules()
|
||||
rules.value = res.rules ?? []
|
||||
raw.value = res.raw
|
||||
dirty.value = false
|
||||
} catch (e) {
|
||||
problem.value = e instanceof ApiError ? e.message : String(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function move(index: number, delta: number) {
|
||||
const target = index + delta
|
||||
if (target < 0 || target >= rules.value.length) return
|
||||
const next = [...rules.value]
|
||||
const [item] = next.splice(index, 1)
|
||||
next.splice(target, 0, item)
|
||||
rules.value = next
|
||||
dirty.value = true
|
||||
}
|
||||
|
||||
function startAdd() {
|
||||
editingIndex.value = -1
|
||||
editing.value = { name: '', action: 'block', log: false, expr: '' }
|
||||
modifierArgs.value = '{}'
|
||||
}
|
||||
|
||||
function startEdit(index: number) {
|
||||
editingIndex.value = index
|
||||
editing.value = { ...rules.value[index] }
|
||||
modifierArgs.value = JSON.stringify(rules.value[index].modifier?.args ?? {}, null, 2)
|
||||
}
|
||||
|
||||
const modifierArgs = ref('{}')
|
||||
const editError = ref('')
|
||||
|
||||
function commitEdit() {
|
||||
const draft = editing.value
|
||||
if (!draft) return
|
||||
if (!draft.name.trim()) {
|
||||
editError.value = t('rules.nameRequired')
|
||||
return
|
||||
}
|
||||
if (!draft.expr.trim()) {
|
||||
editError.value = t('rules.exprRequired')
|
||||
return
|
||||
}
|
||||
if (draft.action === 'modify') {
|
||||
try {
|
||||
const args = JSON.parse(modifierArgs.value || '{}') as Record<string, unknown>
|
||||
draft.modifier = { name: draft.modifier?.name ?? modifierOptions.value[0]?.value ?? '', args }
|
||||
} catch {
|
||||
editError.value = 'JSON'
|
||||
return
|
||||
}
|
||||
} else {
|
||||
draft.modifier = null
|
||||
}
|
||||
const next = [...rules.value]
|
||||
if (editingIndex.value >= 0) next[editingIndex.value] = draft
|
||||
else next.push(draft)
|
||||
rules.value = next
|
||||
dirty.value = true
|
||||
editing.value = null
|
||||
editError.value = ''
|
||||
}
|
||||
|
||||
function remove(index: number) {
|
||||
rules.value = rules.value.filter((_, i) => i !== index)
|
||||
dirty.value = true
|
||||
removing.value = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PageHeader :title="t('rules.title')" :description="t('rules.subtitle')">
|
||||
<template #actions>
|
||||
<Button variant="ghost" size="sm" :disabled="busy" @click="reload">
|
||||
<RotateCcw class="size-3.5" />
|
||||
<span class="hidden sm:inline">{{ t('common.refresh') }}</span>
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" :disabled="busy" @click="validate">
|
||||
<CheckCircle2 class="size-3.5" />
|
||||
<span class="hidden sm:inline">{{ t('rules.validate') }}</span>
|
||||
</Button>
|
||||
<Button size="sm" :disabled="busy" @click="save">
|
||||
<Loader2 v-if="busy" class="size-3.5 animate-spin" />
|
||||
<Save v-else class="size-3.5" />
|
||||
{{ t('rules.save') }}
|
||||
</Button>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Tabs :model-value="mode" @update:model-value="switchMode">
|
||||
<TabsList>
|
||||
<TabsTrigger value="visual">{{ t('rules.visual') }}</TabsTrigger>
|
||||
<TabsTrigger value="yaml">
|
||||
<FileCode2 />
|
||||
{{ t('rules.yaml') }}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Badge variant="secondary">{{ t('rules.count', { n: rules.length }) }}</Badge>
|
||||
<Badge v-if="dirty" variant="drop">{{ t('rules.unsaved') }}</Badge>
|
||||
<span
|
||||
v-if="path"
|
||||
class="text-muted-foreground ml-auto hidden truncate font-mono text-[11px] sm:inline"
|
||||
>{{ path }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="problem"
|
||||
class="border-destructive/40 bg-destructive/10 text-destructive mb-3 rounded-lg border px-3 py-2 font-mono text-xs break-words whitespace-pre-wrap"
|
||||
>
|
||||
{{ problem }}
|
||||
</p>
|
||||
|
||||
<Tabs :model-value="mode" @update:model-value="switchMode">
|
||||
<TabsContent value="visual">
|
||||
<div v-if="loading" class="text-muted-foreground py-12 text-center text-sm">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-2">
|
||||
<Card
|
||||
v-for="(rule, index) in rules"
|
||||
:key="`${rule.name}-${index}`"
|
||||
class="p-3 sm:p-4"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-muted-foreground font-mono text-[11px] tabular-nums"
|
||||
>#{{ index + 1 }}</span
|
||||
>
|
||||
<span class="truncate text-sm font-medium">{{ rule.name }}</span>
|
||||
<Badge v-if="rule.action" :variant="badgeVariant(rule.action)" class="uppercase">
|
||||
{{ t(`action.${rule.action}`) }}
|
||||
</Badge>
|
||||
<Badge v-if="rule.log" variant="log">{{ t('action.log') }}</Badge>
|
||||
<Badge v-if="rule.modifier?.name" variant="outline">
|
||||
{{ rule.modifier.name }}
|
||||
</Badge>
|
||||
</div>
|
||||
<code
|
||||
class="text-muted-foreground bg-muted/60 rounded-md px-2 py-1.5 font-mono text-[11px] leading-relaxed break-all sm:text-xs"
|
||||
>{{ rule.expr }}</code
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex shrink-0 flex-col gap-1 sm:flex-row">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rules.moveUp')"
|
||||
:disabled="index === 0"
|
||||
@click="move(index, -1)"
|
||||
>
|
||||
<ArrowUp class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('rules.moveDown')"
|
||||
:disabled="index === rules.length - 1"
|
||||
@click="move(index, 1)"
|
||||
>
|
||||
<ArrowDown class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
:aria-label="t('common.edit')"
|
||||
@click="startEdit(index)"
|
||||
>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
class="text-destructive"
|
||||
:aria-label="t('common.delete')"
|
||||
@click="removing = index"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<p v-if="!rules.length" class="text-muted-foreground py-10 text-center text-sm">
|
||||
{{ t('rules.empty') }}
|
||||
</p>
|
||||
|
||||
<Button variant="outline" class="mt-1 w-full" @click="startAdd">
|
||||
<Plus class="size-4" />
|
||||
{{ t('rules.add') }}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="yaml">
|
||||
<Textarea
|
||||
v-model="raw"
|
||||
spellcheck="false"
|
||||
class="h-[60vh] resize-y font-mono text-xs leading-relaxed"
|
||||
@update:model-value="dirty = true"
|
||||
/>
|
||||
<p class="text-muted-foreground mt-2 text-xs">{{ t('rules.hint') }}</p>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<!-- Rule editor -->
|
||||
<Modal
|
||||
:open="editing !== null"
|
||||
:title="editingIndex >= 0 ? t('rules.editTitle') : t('rules.addTitle')"
|
||||
@update:open="!$event && ((editing = null), (editError = ''))"
|
||||
>
|
||||
<form v-if="editing" class="flex flex-col gap-4" @submit.prevent="commitEdit">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="rule-name">{{ t('rules.name') }}</Label>
|
||||
<Input id="rule-name" v-model="editing.name" :placeholder="t('rules.namePlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>{{ t('rules.action') }}</Label>
|
||||
<Select
|
||||
:model-value="editing.action ?? ''"
|
||||
:options="actionOptions"
|
||||
@update:model-value="editing.action = $event"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col justify-end gap-2">
|
||||
<Label for="rule-log">{{ t('rules.log') }}</Label>
|
||||
<div class="flex h-9 items-center">
|
||||
<Switch id="rule-log" v-model="editing.log" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="editing.action === 'modify'" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label>{{ t('rules.modifier') }}</Label>
|
||||
<Select
|
||||
:model-value="editing.modifier?.name ?? ''"
|
||||
:options="modifierOptions"
|
||||
@update:model-value="
|
||||
editing.modifier = { name: $event, args: editing.modifier?.args }
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="rule-args">{{ t('rules.modifierArgs') }}</Label>
|
||||
<Textarea id="rule-args" v-model="modifierArgs" class="font-mono text-xs" rows="4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label for="rule-expr">{{ t('rules.expr') }}</Label>
|
||||
<Textarea
|
||||
id="rule-expr"
|
||||
v-model="editing.expr"
|
||||
spellcheck="false"
|
||||
:placeholder="t('rules.exprPlaceholder')"
|
||||
class="min-h-28 font-mono text-xs leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="editError" class="text-destructive text-sm">{{ editError }}</p>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="((editing = null), (editError = ''))">
|
||||
{{ t('common.cancel') }}
|
||||
</Button>
|
||||
<Button @click="commitEdit">{{ t('common.save') }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Delete confirmation -->
|
||||
<Modal
|
||||
:open="removing >= 0"
|
||||
:title="t('common.delete')"
|
||||
:description="
|
||||
removing >= 0 ? t('rules.deleteConfirm', { name: rules[removing]?.name ?? '' }) : ''
|
||||
"
|
||||
@update:open="!$event && (removing = -1)"
|
||||
>
|
||||
<template #footer>
|
||||
<Button variant="outline" @click="removing = -1">{{ t('common.cancel') }}</Button>
|
||||
<Button variant="destructive" @click="remove(removing)">{{ t('common.delete') }}</Button>
|
||||
</template>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import { BookOpen, LogOut, Monitor, Moon, Sun } from 'lucide-vue-next'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import PageHeader from '@/components/PageHeader.vue'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Select,
|
||||
Separator,
|
||||
} from '@/components/ui'
|
||||
import { stopLive } from '@/composables/useLive'
|
||||
import { useTheme, type Theme } from '@/composables/useTheme'
|
||||
import { t, useI18n } from '@/i18n'
|
||||
import { api } from '@/lib/api'
|
||||
import type { Info } from '@/lib/types'
|
||||
|
||||
const router = useRouter()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const { lang, setLang } = useI18n()
|
||||
|
||||
const info = ref<Info | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
info.value = await api.info()
|
||||
} catch {
|
||||
/* the shell already surfaces connection problems */
|
||||
}
|
||||
})
|
||||
|
||||
const themes = computed(() => [
|
||||
{ value: 'light', label: t('settings.themeLight'), icon: Sun },
|
||||
{ value: 'dark', label: t('settings.themeDark'), icon: Moon },
|
||||
{ value: 'system', label: t('settings.themeSystem'), icon: Monitor },
|
||||
])
|
||||
|
||||
const langOptions = [
|
||||
{ value: 'en', label: 'English' },
|
||||
{ value: 'zh', label: '中文' },
|
||||
]
|
||||
|
||||
const rows = computed(() => {
|
||||
const i = info.value
|
||||
if (!i) return []
|
||||
return [
|
||||
{ label: t('settings.version'), value: i.commit ? `${i.version} (${i.commit})` : i.version },
|
||||
{ label: t('settings.platform'), value: i.platform },
|
||||
{ label: t('settings.goVersion'), value: i.goVersion },
|
||||
{ label: t('settings.hostname'), value: i.hostname },
|
||||
{ label: t('settings.ruleFile'), value: i.ruleFile },
|
||||
]
|
||||
})
|
||||
|
||||
const engineRows = computed(() => {
|
||||
const c = info.value?.config
|
||||
if (!c) return []
|
||||
const rows = [
|
||||
{ label: t('settings.workers'), value: c.workers > 0 ? String(c.workers) : t('settings.auto') },
|
||||
{
|
||||
label: t('settings.workerQueueSize'),
|
||||
value: c.workerQueueSize > 0 ? String(c.workerQueueSize) : t('settings.auto'),
|
||||
},
|
||||
{
|
||||
label: t('settings.ioQueueSize'),
|
||||
value: c.ioQueueSize > 0 ? String(c.ioQueueSize) : t('settings.auto'),
|
||||
},
|
||||
{ label: t('settings.ioLocal'), value: c.ioLocal ? t('common.enabled') : t('common.disabled') },
|
||||
{ label: t('settings.ioRST'), value: c.ioRST ? t('common.enabled') : t('common.disabled') },
|
||||
{
|
||||
label: t('settings.udpMaxStreams'),
|
||||
value: c.udpMaxStreams > 0 ? String(c.udpMaxStreams) : t('settings.auto'),
|
||||
},
|
||||
]
|
||||
if (c.geoip) rows.push({ label: t('settings.geoip'), value: c.geoip })
|
||||
if (c.geosite) rows.push({ label: t('settings.geosite'), value: c.geosite })
|
||||
return rows
|
||||
})
|
||||
|
||||
async function logout() {
|
||||
stopLive()
|
||||
await api.logout()
|
||||
await router.replace({ name: 'login' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-3xl">
|
||||
<PageHeader :title="t('settings.title')" :description="t('settings.subtitle')" />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.appearance') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-sm font-medium">{{ t('settings.theme') }}</span>
|
||||
<div class="bg-muted grid grid-cols-3 gap-1 rounded-lg p-1">
|
||||
<button
|
||||
v-for="item in themes"
|
||||
:key="item.value"
|
||||
type="button"
|
||||
class="flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm"
|
||||
:class="
|
||||
theme === item.value
|
||||
? 'bg-background text-foreground shadow-sm'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
"
|
||||
@click="setTheme(item.value as Theme)"
|
||||
>
|
||||
<component :is="item.icon" class="size-3.5" />
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<span class="text-sm font-medium">{{ t('settings.language') }}</span>
|
||||
<Select
|
||||
:model-value="lang"
|
||||
:options="langOptions"
|
||||
class="w-36"
|
||||
:aria-label="t('settings.language')"
|
||||
@update:model-value="setLang($event as 'en' | 'zh')"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="mt-3">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.instance') }}</CardTitle>
|
||||
<CardDescription>{{ t('settings.engineConfig') }}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl class="flex flex-col">
|
||||
<div
|
||||
v-for="row in rows"
|
||||
:key="row.label"
|
||||
class="flex items-start justify-between gap-4 py-1.5 text-sm"
|
||||
>
|
||||
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
|
||||
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<Separator class="my-3" />
|
||||
|
||||
<dl class="flex flex-col">
|
||||
<div
|
||||
v-for="row in engineRows"
|
||||
:key="row.label"
|
||||
class="flex items-start justify-between gap-4 py-1.5 text-sm"
|
||||
>
|
||||
<dt class="text-muted-foreground shrink-0">{{ row.label }}</dt>
|
||||
<dd class="truncate font-mono text-xs sm:text-[13px]">{{ row.value }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card class="mt-3">
|
||||
<CardHeader>
|
||||
<CardTitle>{{ t('settings.session') }}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent class="flex flex-wrap gap-2">
|
||||
<Button variant="destructive" @click="logout">
|
||||
<LogOut class="size-4" />
|
||||
{{ t('settings.logout') }}
|
||||
</Button>
|
||||
<Button variant="outline" as="a" href="https://gfw.dev/" target="_blank" rel="noreferrer">
|
||||
<BookOpen class="size-4" />
|
||||
{{ t('settings.docs') }}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// The production build lands in ../dist, which is embedded into the Go binary.
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 900,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.OPENGFW_API ?? 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
+513
@@ -0,0 +1,513 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PropMap is a set of properties produced by a single analyzer.
|
||||
type PropMap = map[string]interface{}
|
||||
|
||||
// Props is the combined property map of all analyzers of a stream.
|
||||
type Props = map[string]PropMap
|
||||
|
||||
// Event kinds.
|
||||
const (
|
||||
EventKindAction = "action"
|
||||
EventKindLog = "log"
|
||||
EventKindError = "error"
|
||||
)
|
||||
|
||||
// Event is a single entry of the live event feed shown by the web UI.
|
||||
type Event struct {
|
||||
Seq uint64 `json:"seq"`
|
||||
Time int64 `json:"time"` // Unix milliseconds
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Proto string `json:"proto"`
|
||||
SrcIP string `json:"srcIP"`
|
||||
SrcPort uint16 `json:"srcPort"`
|
||||
DstIP string `json:"dstIP"`
|
||||
DstPort uint16 `json:"dstPort"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Rule string `json:"rule,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Props Props `json:"props,omitempty"`
|
||||
}
|
||||
|
||||
// Bucket is a single time slot of the traffic time series.
|
||||
type Bucket struct {
|
||||
Time int64 `json:"time"` // Unix milliseconds, start of the bucket
|
||||
TCP uint64 `json:"tcp"`
|
||||
UDP uint64 `json:"udp"`
|
||||
Blocked uint64 `json:"blocked"`
|
||||
Allowed uint64 `json:"allowed"`
|
||||
}
|
||||
|
||||
// NameCount is a name/count pair used by the various "top N" lists.
|
||||
type NameCount struct {
|
||||
Name string `json:"name"`
|
||||
Count uint64 `json:"count"`
|
||||
}
|
||||
|
||||
const (
|
||||
defaultEventBufferSize = 512
|
||||
subscriberQueueSize = 256
|
||||
maxCounterEntries = 4096
|
||||
topNLimit = 10
|
||||
)
|
||||
|
||||
// Hub collects statistics from the engine and fans live events out to the
|
||||
// connected web UI clients. All methods are safe for concurrent use, and the
|
||||
// ones called from the engine hot path are kept as cheap as possible.
|
||||
type Hub struct {
|
||||
startTime time.Time
|
||||
|
||||
// Hot path counters. Updated with atomics, no locking involved.
|
||||
tcpStreams atomic.Uint64
|
||||
udpStreams atomic.Uint64
|
||||
actAllow atomic.Uint64
|
||||
actBlock atomic.Uint64
|
||||
actDrop atomic.Uint64
|
||||
actModify atomic.Uint64
|
||||
actMaybe atomic.Uint64
|
||||
errors atomic.Uint64
|
||||
workersAlive atomic.Int64
|
||||
|
||||
mu sync.Mutex
|
||||
seq uint64
|
||||
events []*Event // ring buffer, oldest first once wrapped
|
||||
evHead int
|
||||
evFilled bool
|
||||
|
||||
short *series // 10 seconds per bucket, 10 minutes total
|
||||
long *series // 1 minute per bucket, 1 hour total
|
||||
|
||||
hosts *counterMap
|
||||
dstIPs *counterMap
|
||||
rules *counterMap
|
||||
analyzers *counterMap
|
||||
|
||||
subs map[int]chan *Event
|
||||
next int
|
||||
}
|
||||
|
||||
// NewHub creates a new statistics hub.
|
||||
func NewHub() *Hub {
|
||||
return &Hub{
|
||||
startTime: time.Now(),
|
||||
events: make([]*Event, defaultEventBufferSize),
|
||||
short: newSeries(10*time.Second, 60),
|
||||
long: newSeries(time.Minute, 60),
|
||||
hosts: newCounterMap(),
|
||||
dstIPs: newCounterMap(),
|
||||
rules: newCounterMap(),
|
||||
analyzers: newCounterMap(),
|
||||
subs: make(map[int]chan *Event),
|
||||
}
|
||||
}
|
||||
|
||||
// WorkerStarted / WorkerStopped track the number of running workers.
|
||||
func (h *Hub) WorkerStarted() { h.workersAlive.Add(1) }
|
||||
func (h *Hub) WorkerStopped() { h.workersAlive.Add(-1) }
|
||||
|
||||
// StreamNew records a newly seen stream.
|
||||
func (h *Hub) StreamNew(proto string) {
|
||||
if proto == "udp" {
|
||||
h.udpStreams.Add(1)
|
||||
} else {
|
||||
h.tcpStreams.Add(1)
|
||||
}
|
||||
h.mu.Lock()
|
||||
now := time.Now()
|
||||
h.short.add(now, proto, "")
|
||||
h.long.add(now, proto, "")
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// PropUpdate records the analyzer properties of a stream. It is used to build
|
||||
// the "top hosts" list and the per-analyzer hit counters.
|
||||
func (h *Hub) PropUpdate(props Props) {
|
||||
if len(props) == 0 {
|
||||
return
|
||||
}
|
||||
host := HostOf(props)
|
||||
h.mu.Lock()
|
||||
for name := range props {
|
||||
h.analyzers.inc(name)
|
||||
}
|
||||
if host != "" {
|
||||
h.hosts.inc(host)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// StreamAction records the verdict of a stream and, unless the verdict is the
|
||||
// neutral "maybe", publishes an event to the live feed.
|
||||
func (h *Hub) StreamAction(info StreamInfo, action string) {
|
||||
switch action {
|
||||
case "allow":
|
||||
h.actAllow.Add(1)
|
||||
case "block":
|
||||
h.actBlock.Add(1)
|
||||
case "drop":
|
||||
h.actDrop.Add(1)
|
||||
case "modify":
|
||||
h.actModify.Add(1)
|
||||
default:
|
||||
h.actMaybe.Add(1)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
h.short.add(now, "", action)
|
||||
h.long.add(now, "", action)
|
||||
if action == "block" || action == "drop" {
|
||||
if info.DstIP != "" {
|
||||
h.dstIPs.inc(info.DstIP)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
h.publish(now, EventKindAction, info, action, "", "")
|
||||
}
|
||||
|
||||
// RuleLog records a `log: true` hit of a ruleset rule.
|
||||
func (h *Hub) RuleLog(info StreamInfo, rule string) {
|
||||
h.mu.Lock()
|
||||
h.rules.inc(rule)
|
||||
h.mu.Unlock()
|
||||
h.publish(time.Now(), EventKindLog, info, "", rule, "")
|
||||
}
|
||||
|
||||
// Error records an engine or ruleset error.
|
||||
func (h *Hub) Error(info StreamInfo, rule, message string) {
|
||||
h.errors.Add(1)
|
||||
h.publish(time.Now(), EventKindError, info, "", rule, message)
|
||||
}
|
||||
|
||||
// StreamInfo is the subset of ruleset.StreamInfo the hub cares about.
|
||||
type StreamInfo struct {
|
||||
ID int64
|
||||
Proto string
|
||||
SrcIP string
|
||||
SrcPort uint16
|
||||
DstIP string
|
||||
DstPort uint16
|
||||
Props Props
|
||||
}
|
||||
|
||||
func (h *Hub) publish(now time.Time, kind string, info StreamInfo, action, rule, message string) {
|
||||
h.mu.Lock()
|
||||
h.seq++
|
||||
ev := &Event{
|
||||
Seq: h.seq,
|
||||
Time: now.UnixMilli(),
|
||||
Kind: kind,
|
||||
ID: info.ID,
|
||||
Proto: info.Proto,
|
||||
SrcIP: info.SrcIP,
|
||||
SrcPort: info.SrcPort,
|
||||
DstIP: info.DstIP,
|
||||
DstPort: info.DstPort,
|
||||
Action: action,
|
||||
Rule: rule,
|
||||
Message: message,
|
||||
Host: HostOf(info.Props),
|
||||
Props: info.Props,
|
||||
}
|
||||
h.events[h.evHead] = ev
|
||||
h.evHead = (h.evHead + 1) % len(h.events)
|
||||
if h.evHead == 0 {
|
||||
h.evFilled = true
|
||||
}
|
||||
for _, ch := range h.subs {
|
||||
select {
|
||||
case ch <- ev:
|
||||
default: // Slow client, drop the event rather than blocking the engine
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// Subscribe returns a channel of live events and a function to unsubscribe.
|
||||
func (h *Hub) Subscribe() (<-chan *Event, func()) {
|
||||
ch := make(chan *Event, subscriberQueueSize)
|
||||
h.mu.Lock()
|
||||
id := h.next
|
||||
h.next++
|
||||
h.subs[id] = ch
|
||||
h.mu.Unlock()
|
||||
return ch, func() {
|
||||
h.mu.Lock()
|
||||
if c, ok := h.subs[id]; ok {
|
||||
delete(h.subs, id)
|
||||
close(c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Events returns up to limit most recent events, newest first.
|
||||
func (h *Hub) Events(limit int) []*Event {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
size := len(h.events)
|
||||
n := h.evHead
|
||||
if h.evFilled {
|
||||
n = size
|
||||
}
|
||||
if limit <= 0 || limit > n {
|
||||
limit = n
|
||||
}
|
||||
out := make([]*Event, 0, limit)
|
||||
for i := 0; i < limit; i++ {
|
||||
idx := (h.evHead - 1 - i + size*2) % size
|
||||
if h.events[idx] == nil {
|
||||
break
|
||||
}
|
||||
out = append(out, h.events[idx])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Metrics is the snapshot of everything the dashboard needs.
|
||||
type Metrics struct {
|
||||
UptimeSeconds int64 `json:"uptimeSeconds"`
|
||||
Workers int64 `json:"workers"`
|
||||
Streams StreamStats `json:"streams"`
|
||||
Actions ActionStats `json:"actions"`
|
||||
Errors uint64 `json:"errors"`
|
||||
Short []Bucket `json:"short"`
|
||||
Long []Bucket `json:"long"`
|
||||
TopHosts []NameCount `json:"topHosts"`
|
||||
TopBlockedIPs []NameCount `json:"topBlockedIPs"`
|
||||
TopRules []NameCount `json:"topRules"`
|
||||
Analyzers []NameCount `json:"analyzers"`
|
||||
}
|
||||
|
||||
type StreamStats struct {
|
||||
TCP uint64 `json:"tcp"`
|
||||
UDP uint64 `json:"udp"`
|
||||
Total uint64 `json:"total"`
|
||||
}
|
||||
|
||||
type ActionStats struct {
|
||||
Allow uint64 `json:"allow"`
|
||||
Block uint64 `json:"block"`
|
||||
Drop uint64 `json:"drop"`
|
||||
Modify uint64 `json:"modify"`
|
||||
Maybe uint64 `json:"maybe"`
|
||||
}
|
||||
|
||||
// Metrics returns a consistent snapshot of the collected statistics.
|
||||
func (h *Hub) Metrics() Metrics {
|
||||
tcp, udp := h.tcpStreams.Load(), h.udpStreams.Load()
|
||||
m := Metrics{
|
||||
UptimeSeconds: int64(time.Since(h.startTime).Seconds()),
|
||||
Workers: h.workersAlive.Load(),
|
||||
Streams: StreamStats{TCP: tcp, UDP: udp, Total: tcp + udp},
|
||||
Actions: ActionStats{
|
||||
Allow: h.actAllow.Load(),
|
||||
Block: h.actBlock.Load(),
|
||||
Drop: h.actDrop.Load(),
|
||||
Modify: h.actModify.Load(),
|
||||
Maybe: h.actMaybe.Load(),
|
||||
},
|
||||
Errors: h.errors.Load(),
|
||||
}
|
||||
now := time.Now()
|
||||
h.mu.Lock()
|
||||
m.Short = h.short.snapshot(now)
|
||||
m.Long = h.long.snapshot(now)
|
||||
m.TopHosts = h.hosts.top(topNLimit)
|
||||
m.TopBlockedIPs = h.dstIPs.top(topNLimit)
|
||||
m.TopRules = h.rules.top(topNLimit)
|
||||
m.Analyzers = h.analyzers.top(maxCounterEntries)
|
||||
h.mu.Unlock()
|
||||
return m
|
||||
}
|
||||
|
||||
// HostOf extracts the most meaningful hostname out of a property map, so that
|
||||
// the UI can show something more useful than a bare IP address.
|
||||
func HostOf(props Props) string {
|
||||
if props == nil {
|
||||
return ""
|
||||
}
|
||||
for _, path := range [][]string{
|
||||
{"tls", "req", "sni"},
|
||||
{"quic", "req", "sni"},
|
||||
{"http", "req", "headers", "host"},
|
||||
{"dns", "questions", "0", "name"},
|
||||
} {
|
||||
if s := nestedString(props, path...); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// nestedString walks a path of keys (or numeric slice indices) and returns the
|
||||
// value at the end of it if it is a string. Analyzers use named map types, so
|
||||
// the lookups go through reflection instead of plain type assertions.
|
||||
func nestedString(root interface{}, path ...string) string {
|
||||
cur := root
|
||||
for _, key := range path {
|
||||
next, ok := index(cur, key)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
s, _ := cur.(string)
|
||||
return s
|
||||
}
|
||||
|
||||
func index(container interface{}, key string) (interface{}, bool) {
|
||||
if container == nil {
|
||||
return nil, false
|
||||
}
|
||||
if m, ok := container.(map[string]interface{}); ok {
|
||||
v, ok := m[key]
|
||||
return v, ok
|
||||
}
|
||||
v := reflect.ValueOf(container)
|
||||
switch v.Kind() {
|
||||
case reflect.Map:
|
||||
if v.Type().Key().Kind() != reflect.String {
|
||||
return nil, false
|
||||
}
|
||||
mv := v.MapIndex(reflect.ValueOf(key).Convert(v.Type().Key()))
|
||||
if !mv.IsValid() {
|
||||
return nil, false
|
||||
}
|
||||
return mv.Interface(), true
|
||||
case reflect.Slice, reflect.Array:
|
||||
i, err := strconv.Atoi(key)
|
||||
if err != nil || i < 0 || i >= v.Len() {
|
||||
return nil, false
|
||||
}
|
||||
return v.Index(i).Interface(), true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// series is a fixed size ring of time buckets.
|
||||
type series struct {
|
||||
dur time.Duration
|
||||
buckets []Bucket
|
||||
head int // index of the newest bucket
|
||||
}
|
||||
|
||||
func newSeries(dur time.Duration, size int) *series {
|
||||
s := &series{dur: dur, buckets: make([]Bucket, size)}
|
||||
now := time.Now()
|
||||
for i := range s.buckets {
|
||||
s.buckets[i].Time = s.align(now.Add(-time.Duration(len(s.buckets)-1-i) * dur))
|
||||
}
|
||||
s.head = len(s.buckets) - 1
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *series) align(t time.Time) int64 {
|
||||
return t.Truncate(s.dur).UnixMilli()
|
||||
}
|
||||
|
||||
// rotate advances the ring so that the newest bucket matches now.
|
||||
func (s *series) rotate(now time.Time) *Bucket {
|
||||
ts := s.align(now)
|
||||
head := &s.buckets[s.head]
|
||||
if head.Time == ts {
|
||||
return head
|
||||
}
|
||||
if ts < head.Time {
|
||||
// Clock went backwards, keep using the current bucket.
|
||||
return head
|
||||
}
|
||||
steps := int((ts - head.Time) / s.dur.Milliseconds())
|
||||
if steps > len(s.buckets) {
|
||||
steps = len(s.buckets)
|
||||
}
|
||||
for i := 0; i < steps; i++ {
|
||||
s.head = (s.head + 1) % len(s.buckets)
|
||||
s.buckets[s.head] = Bucket{Time: head.Time + int64(i+1)*s.dur.Milliseconds()}
|
||||
}
|
||||
s.buckets[s.head].Time = ts
|
||||
return &s.buckets[s.head]
|
||||
}
|
||||
|
||||
func (s *series) add(now time.Time, proto, action string) {
|
||||
b := s.rotate(now)
|
||||
switch proto {
|
||||
case "tcp":
|
||||
b.TCP++
|
||||
case "udp":
|
||||
b.UDP++
|
||||
}
|
||||
switch action {
|
||||
case "block", "drop":
|
||||
b.Blocked++
|
||||
case "allow":
|
||||
b.Allowed++
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot returns the buckets in chronological order.
|
||||
func (s *series) snapshot(now time.Time) []Bucket {
|
||||
s.rotate(now)
|
||||
out := make([]Bucket, 0, len(s.buckets))
|
||||
for i := 1; i <= len(s.buckets); i++ {
|
||||
out = append(out, s.buckets[(s.head+i)%len(s.buckets)])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// counterMap is a bounded string counter used for the "top N" lists.
|
||||
type counterMap struct {
|
||||
m map[string]uint64
|
||||
}
|
||||
|
||||
func newCounterMap() *counterMap {
|
||||
return &counterMap{m: make(map[string]uint64)}
|
||||
}
|
||||
|
||||
func (c *counterMap) inc(key string) {
|
||||
if len(c.m) >= maxCounterEntries {
|
||||
if _, ok := c.m[key]; !ok {
|
||||
c.prune()
|
||||
}
|
||||
}
|
||||
c.m[key]++
|
||||
}
|
||||
|
||||
// prune keeps the most frequent half of the entries.
|
||||
func (c *counterMap) prune() {
|
||||
keep := c.top(maxCounterEntries / 2)
|
||||
m := make(map[string]uint64, len(keep))
|
||||
for _, kv := range keep {
|
||||
m[kv.Name] = kv.Count
|
||||
}
|
||||
c.m = m
|
||||
}
|
||||
|
||||
func (c *counterMap) top(n int) []NameCount {
|
||||
out := make([]NameCount, 0, len(c.m))
|
||||
for k, v := range c.m {
|
||||
out = append(out, NameCount{Name: k, Count: v})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Count != out[j].Count {
|
||||
return out[i].Count > out[j].Count
|
||||
}
|
||||
return out[i].Name < out[j].Name
|
||||
})
|
||||
if len(out) > n {
|
||||
out = out[:n]
|
||||
}
|
||||
return out
|
||||
}
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
errBadCredentials = errors.New("invalid password")
|
||||
errTooManyAttempts = errors.New("too many failed attempts, try again later")
|
||||
errInternal = errors.New("internal error")
|
||||
)
|
||||
|
||||
// Rule is the JSON representation of a single ruleset rule. The YAML tags
|
||||
// mirror the rule file format, so the same struct can be written back out.
|
||||
type Rule struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Action string `json:"action,omitempty" yaml:"action,omitempty"`
|
||||
Log bool `json:"log,omitempty" yaml:"log,omitempty"`
|
||||
Modifier *RuleModifier `json:"modifier,omitempty" yaml:"modifier,omitempty"`
|
||||
Expr string `json:"expr" yaml:"expr"`
|
||||
}
|
||||
|
||||
// RuleModifier is the modifier attached to a `modify` rule.
|
||||
type RuleModifier struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Args map[string]interface{} `json:"args,omitempty" yaml:"args,omitempty"`
|
||||
}
|
||||
|
||||
// RuleManager gives the web server access to the running ruleset. The
|
||||
// implementation lives in the cmd package, which owns both the rule file and
|
||||
// the engine.
|
||||
type RuleManager interface {
|
||||
// Path returns the path of the rule file.
|
||||
Path() string
|
||||
// Load reads the rule file and returns its raw content and parsed rules.
|
||||
Load() (string, []Rule, error)
|
||||
// Validate parses and compiles the given YAML without applying it.
|
||||
Validate(raw string) ([]Rule, error)
|
||||
// Marshal serializes structured rules back to YAML.
|
||||
Marshal(rules []Rule) (string, error)
|
||||
// Apply validates the given YAML, writes it to the rule file and hot
|
||||
// reloads the engine.
|
||||
Apply(raw string) ([]Rule, error)
|
||||
}
|
||||
|
||||
// Info is the static information about the running instance shown by the UI.
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Platform string `json:"platform"`
|
||||
GoVersion string `json:"goVersion"`
|
||||
Hostname string `json:"hostname"`
|
||||
RuleFile string `json:"ruleFile"`
|
||||
Config ConfigDigest `json:"config"`
|
||||
}
|
||||
|
||||
// ConfigDigest is a read-only summary of the engine configuration.
|
||||
type ConfigDigest struct {
|
||||
IOQueueSize uint32 `json:"ioQueueSize"`
|
||||
IOLocal bool `json:"ioLocal"`
|
||||
IORST bool `json:"ioRST"`
|
||||
Workers int `json:"workers"`
|
||||
WorkerQueue int `json:"workerQueueSize"`
|
||||
UDPMaxStreams int `json:"udpMaxStreams"`
|
||||
GeoIP string `json:"geoip,omitempty"`
|
||||
GeoSite string `json:"geosite,omitempty"`
|
||||
}
|
||||
|
||||
// MetaInfo describes what the engine is capable of. The rule editor uses it
|
||||
// for autocompletion and validation hints.
|
||||
type MetaInfo struct {
|
||||
Analyzers []AnalyzerInfo `json:"analyzers"`
|
||||
Modifiers []string `json:"modifiers"`
|
||||
Actions []string `json:"actions"`
|
||||
Functions []string `json:"functions"`
|
||||
}
|
||||
|
||||
// AnalyzerInfo describes a single analyzer.
|
||||
type AnalyzerInfo struct {
|
||||
Name string `json:"name"`
|
||||
Proto string `json:"proto"`
|
||||
}
|
||||
|
||||
// Config is the web server configuration.
|
||||
type Config struct {
|
||||
// Listen is the address to listen on, e.g. ":8080".
|
||||
Listen string
|
||||
// Secret is the password required to log in. Must not be empty.
|
||||
Secret string
|
||||
// CertFile / KeyFile enable HTTPS when both are set.
|
||||
CertFile string
|
||||
KeyFile string
|
||||
|
||||
Hub *Hub
|
||||
Rules RuleManager
|
||||
Meta MetaInfo
|
||||
Info func() Info
|
||||
|
||||
// Logf is used for the few messages the server produces. Optional.
|
||||
Logf func(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// Server serves the web UI and its API.
|
||||
type Server struct {
|
||||
config Config
|
||||
auth *authenticator
|
||||
mux *http.ServeMux
|
||||
static http.Handler
|
||||
}
|
||||
|
||||
// NewServer creates a new web UI server.
|
||||
func NewServer(config Config) (*Server, error) {
|
||||
if config.Hub == nil {
|
||||
return nil, errors.New("web: hub is required")
|
||||
}
|
||||
if config.Secret == "" {
|
||||
return nil, errors.New("web: secret is required")
|
||||
}
|
||||
if config.Listen == "" {
|
||||
config.Listen = ":8080"
|
||||
}
|
||||
if config.Logf == nil {
|
||||
config.Logf = func(string, ...interface{}) {}
|
||||
}
|
||||
s := &Server{
|
||||
config: config,
|
||||
auth: newAuthenticator(config.Secret),
|
||||
mux: http.NewServeMux(),
|
||||
static: staticHandler(),
|
||||
}
|
||||
s.routes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Secret returns the password in use, which is useful when it was generated.
|
||||
func (s *Server) Secret() string { return s.config.Secret }
|
||||
|
||||
// Addr returns the address the server listens on.
|
||||
func (s *Server) Addr() string { return s.config.Listen }
|
||||
|
||||
// TLS reports whether the server serves HTTPS.
|
||||
func (s *Server) TLS() bool { return s.config.CertFile != "" && s.config.KeyFile != "" }
|
||||
|
||||
func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/v1/login", s.handleLogin)
|
||||
s.mux.HandleFunc("/api/v1/logout", s.guard(s.handleLogout, true))
|
||||
s.mux.HandleFunc("/api/v1/info", s.guard(s.handleInfo, false))
|
||||
s.mux.HandleFunc("/api/v1/meta", s.guard(s.handleMeta, false))
|
||||
s.mux.HandleFunc("/api/v1/metrics", s.guard(s.handleMetrics, false))
|
||||
s.mux.HandleFunc("/api/v1/events", s.guard(s.handleEvents, false))
|
||||
s.mux.HandleFunc("/api/v1/live", s.guard(s.handleLive, false))
|
||||
s.mux.HandleFunc("/api/v1/rules", s.guard(s.handleRules, false))
|
||||
s.mux.HandleFunc("/api/v1/rules/validate", s.guard(s.handleRulesValidate, true))
|
||||
s.mux.HandleFunc("/", s.handleStatic)
|
||||
}
|
||||
|
||||
// Run starts the server and blocks until the context is cancelled.
|
||||
func (s *Server) Run(ctx context.Context) error {
|
||||
srv := &http.Server{
|
||||
Handler: s.securityHeaders(s.mux),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
BaseContext: func(net.Listener) context.Context { return ctx },
|
||||
}
|
||||
ln, err := net.Listen("tcp", s.config.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
errChan := make(chan error, 1)
|
||||
go func() {
|
||||
if s.TLS() {
|
||||
errChan <- srv.ServeTLS(ln, s.config.CertFile, s.config.KeyFile)
|
||||
} else {
|
||||
errChan <- srv.Serve(ln)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
return nil
|
||||
case err := <-errChan:
|
||||
if errors.Is(err, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// guard wraps a handler with authentication. When mutating is true, a bearer
|
||||
// token is required (a session cookie alone is not enough), which makes the
|
||||
// endpoint immune to cross-site request forgery.
|
||||
func (s *Server) guard(next http.HandlerFunc, mutating bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
token := bearerToken(r)
|
||||
if token == "" && !mutating && r.Method == http.MethodGet {
|
||||
token = cookieToken(r)
|
||||
}
|
||||
if !s.auth.valid(token) {
|
||||
writeError(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, "/api/") {
|
||||
writeError(w, http.StatusNotFound, "not found")
|
||||
return
|
||||
}
|
||||
s.static.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// staticHandler serves the embedded single page application, falling back to
|
||||
// index.html so that client side routing works on a hard refresh.
|
||||
func staticHandler() http.Handler {
|
||||
sub, err := fs.Sub(distFS, "dist")
|
||||
if err != nil {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "web UI is not built", http.StatusNotImplemented)
|
||||
})
|
||||
}
|
||||
files := http.FileServer(http.FS(sub))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
|
||||
if name == "" || name == "." {
|
||||
name = "index.html"
|
||||
}
|
||||
if _, err := fs.Stat(sub, name); err != nil {
|
||||
// Unknown path: let the SPA router handle it.
|
||||
r = r.Clone(r.Context())
|
||||
r.URL.Path = "/"
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(name, "assets/") {
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user