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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 08:09:11 +08:00
Toby 393c29bd2d Merge pull request #123 from apernet/wip-lookup
Quality check / Tests (push) Successful in 9m48s
Quality check / Static analysis (push) Failing after 12m37s
feat: dns lookup function
2024-04-07 17:49:33 -07:00
Toby 9c0893c512 feat: added protected dial support, removed multi-IO support for simplicity 2024-04-06 14:42:45 -07:00
Toby ae34b4856a feat: dns lookup function 2024-04-03 20:02:57 -07:00
Toby d7737e9211 Merge pull request #119 from apernet/update-readme
docs: update readme feature list
2024-04-01 21:50:07 -07:00
Toby dd9ecc3dd7 docs: update readme feature list 2024-04-01 21:49:06 -07:00
Toby 980d59ed2e Merge pull request #118 from apernet/ci-release
ci: rework release workflow
2024-04-01 20:55:22 -07:00
Toby af14adf313 ci: rework release workflow 2024-04-01 20:54:24 -07:00
Toby ab28fc238d Merge pull request #114 from KujouRinka/feat-openvpn
feat: add openVPN analyzer
2024-04-01 20:47:30 -07:00
Toby e535769086 fix: make sure rx_pkt_cnt & tx_pkt_cnt both always exist 2024-03-30 14:30:20 -07:00
Toby ecd60d0ff1 chore: improve case spelling 2024-03-30 13:29:19 -07:00
Toby 98264d9e27 chore: format 2024-03-30 13:23:46 -07:00
Toby bb5d4e32ff Merge pull request #116 from apernet/update-trojan
feat: further improve trojan detection
2024-03-29 23:19:36 -07:00
Toby ca574393d3 feat: further improve trojan detection 2024-03-29 23:02:20 -07:00
KujouRinka 0e2ee36865 refactor: merge openvpn_tcp and openvpn_udp to openvpn 2024-03-30 12:00:17 +08:00
Toby b02738cde8 Merge pull request #115 from apernet/update-readme
docs: move to website
2024-03-29 13:08:14 -07:00
Toby 0735fa831d docs: move to website 2024-03-29 13:06:29 -07:00
KujouRinka 2232b553b3 chore: fix typo 2024-03-29 21:37:09 +08:00
KujouRinka b2f6dec909 feat: add openVPN analyzer 2024-03-29 20:29:30 +08:00
Toby 47a3c9875c Merge pull request #112 from apernet/docs-dep
docs: update dependency install command for openwrt
2024-03-24 11:42:10 -07:00
Toby 4e604904af docs: update dependency install command for openwrt 2024-03-24 11:41:17 -07:00
Toby bf2988116a Merge pull request #109 from apernet/wip-io-rst
feat: io tcp reset support (forward only)
2024-03-21 18:43:43 -07:00
Haruue ef1416274d refactor(io): nft/ipt generator func 2024-03-22 02:04:25 +08:00
Toby 57c818038c feat: io tcp reset support (forward only) 2024-03-20 19:01:26 -07:00
Toby 6ad7714c9a Merge pull request #108 from apernet/fix-trojan-doc
docs: remove trojan-killer ref
2024-03-20 18:41:45 -07:00
Toby ff9c4ccf79 docs: remove trojan-killer ref 2024-03-20 18:40:49 -07:00
Toby e1d9406fdb Merge pull request #107 from apernet/fix-trojan
fix: trojan analyzer heuristics
2024-03-20 18:39:05 -07:00
Toby b8e5079b8a fix: trojan analyzer heuristics 2024-03-20 18:37:43 -07:00
Toby f3b72895ad Merge pull request #106 from apernet/wip-new-trojan
feat: new heuristics for trojan analyzer
2024-03-20 18:11:09 -07:00
Toby 0732dfa7a5 docs: no longer use trojan-killer 2024-03-20 18:10:22 -07:00
Toby 9d96acd8db feat: new heuristics for trojan analyzer 2024-03-20 18:07:26 -07:00
Toby d1775184ce Merge pull request #102 from macie/badge_fix
docs: Fix status badge URL
2024-03-14 00:37:37 -07:00
macie 05d56616fc ci: Add readable name for workflow
It is visible on Workflows list inside GH Actions tab.
2024-03-14 08:14:30 +01:00
macie ede70e1a87 docs: Fix badge URL 2024-03-14 08:06:27 +01:00
Toby 920783bd65 Merge pull request #98 from macie/ci
CI/CD: New workflow for testing and static analysis
2024-03-13 19:29:36 -07:00
macie 3a45461c19 test: Remove broken integration test
This tests fails when there are no specific binary files, but there is
no method to get the files during test.

Marking the tests as a skipped will gives us a false sense of security. So it's
better to remove it at all.
2024-03-12 12:15:10 +01:00
macie 3022bde81b fix: Linter errors
Fixed:
- "ineffective break statement. Did you mean to break out of the outer loop?" (SA4011)
- "channels used with os/signal.Notify should be buffered" (SA1017)
- "os.Kill cannot be trapped (did you mean syscall.SIGTERM?)" (SA1016)
- "func envOrDefaultBool is unused" (U1000)
- "should use time.Since instead of time.Now().Sub" (S1012)
2024-03-12 12:14:58 +01:00
macie d98136bac7 ci: Add quality check
Runs tests and linters after each commit.
2024-03-11 22:19:26 +01:00
macie c0e2483f6c test: Add basic tests for packet parsing
Tests performed on real-like packets secure expected behavior.
2024-03-11 21:25:34 +01:00
macie 3bd02ed46e refactor: Improve parsing docs
Reveal intentions by:
- extracting magic numbers into constants
- changing function names with >1 responsibilities
- documenting non-obvious behaviors.
2024-03-11 20:35:01 +01:00
Toby 4257788f33 Merge pull request #92 from apernet/wip-doc-openwrt-ipt
docs: instruction for OpenWrt 22.02 or earlier
2024-03-09 09:23:53 -08:00
Haruue e77c2fabea docs: instruction for OpenWrt 22.02 or earlier 2024-03-09 21:42:49 +08:00
128 changed files with 12110 additions and 1169 deletions
+76
View File
@@ -0,0 +1,76 @@
name: Quality check
on:
push:
branches:
- "*"
pull_request:
permissions:
contents: read
jobs:
static-analysis:
name: Static analysis
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: go vet ./...
- name: staticcheck
uses: dominikh/staticcheck-action@v1.3.0
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
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 'stable'
- run: go test ./...
+32 -7
View File
@@ -1,3 +1,4 @@
name: Release
on:
release:
types: [published]
@@ -14,13 +15,37 @@ jobs:
matrix:
goos: [linux]
goarch: ["386", amd64, arm64]
steps:
- uses: actions/checkout@v4
- uses: wangyoucao577/go-release-action@v1
- name: Setup Go
uses: actions/setup-go@v5
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
goversion: "https://go.dev/dl/go1.22.0.linux-amd64.tar.gz"
binary_name: "OpenGFW"
extra_files: LICENSE README.md README.zh.md
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 }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: 0
run: |
mkdir -p build
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
with:
files: build/*
+13
View File
@@ -205,3 +205,16 @@ $RECYCLE.BIN/
*.lnk
# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,go,goland+all,visualstudiocode
# Internal tools not ready for public use yet
tools/flowseq/
### Geo databases downloaded at runtime ###
geoip.dat
geosite.dat
### Web UI ###
# The built UI in web/dist IS committed, since it is embedded into the binary.
web/frontend/node_modules/
web/frontend/*.tsbuildinfo
web/frontend/.vite/
+76
View File
@@ -0,0 +1,76 @@
GO ?= go
NPM ?= npm
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null)
LDFLAGS := -s -w \
-X github.com/apernet/OpenGFW/cmd.appVersion=$(VERSION) \
-X github.com/apernet/OpenGFW/cmd.appCommit=$(COMMIT)
FRONTEND := web/frontend
.PHONY: all
all: web build
## build: build the OpenGFW binary (embeds the web UI in web/dist)
.PHONY: build
build:
$(GO) build -o OpenGFW -ldflags "$(LDFLAGS)" .
## web: build the web UI into web/dist
.PHONY: web
web:
cd $(FRONTEND) && $(NPM) install && $(NPM) run build
## web-dev: run the Vite dev server against a local OpenGFW instance
.PHONY: web-dev
web-dev:
cd $(FRONTEND) && $(NPM) run dev
## devserver: serve the web UI with synthetic data (works on any OS)
.PHONY: devserver
devserver:
$(GO) run ./web/devserver
.PHONY: test
test:
$(GO) test ./...
.PHONY: vet
vet:
$(GO) vet ./...
.PHONY: clean
clean:
rm -f OpenGFW
rm -rf $(FRONTEND)/node_modules
.PHONY: install
install: build
@echo "Installing OpenGFW to /usr/local/bin"
@install -m 755 OpenGFW /usr/local/bin/OpenGFW || (echo "install failed: try running as root or use sudo" && exit 1)
@echo "Installing systemd unit to /etc/systemd/system/opengfw.service"
@printf '%s\n' \
'[Unit]' \
'Description=OpenGFW' \
'After=network.target' \
'' \
'[Service]' \
'ExecStart=/usr/local/bin/OpenGFW -c /etc/opengfw/config.yaml /etc/opengfw/rules.yaml' \
'Restart=on-failure' \
'User=root' \
'Group=root' \
'WorkingDirectory=/etc/opengfw' \
'' \
'[Install]' \
'WantedBy=multi-user.target' \
> /tmp/opengfw.service || (echo "write failed: try running as root or use sudo" && exit 1)
@echo "Installing default config to /etc/opengfw/"
@mkdir -p /etc/opengfw || (echo "mkdir failed: try running as root or use sudo" && exit 1)
@install -m 644 config.yaml /etc/opengfw/config.yaml || (echo "copy config failed: try running as root or use sudo" && exit 1)
@install -m 644 rules.yaml /etc/opengfw/rules.yaml || (echo "copy rules failed: try running as root or use sudo" && exit 1)
@mv /tmp/opengfw.service /etc/systemd/system/opengfw.service || (echo "move failed: try running as root or use sudo" && exit 1)
@echo "Reloading systemd daemon and enabling service"
@systemctl daemon-reload || (echo "daemon-reload failed: ensure systemd is available" && exit 1)
@systemctl enable --now opengfw.service || (echo "enabling service failed: try running as root or use sudo" && exit 1)
+33 -126
View File
@@ -1,5 +1,6 @@
# ![OpenGFW](docs/logo.png)
[![Quality check status](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml/badge.svg)](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
[![License][1]][2]
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
@@ -7,20 +8,19 @@
OpenGFW は、あなた専用の DIY 中国のグレートファイアウォール (https://en.wikipedia.org/wiki/Great_Firewall) です。Linux 上で利用可能な柔軟で使いやすいオープンソースプログラムとして提供されています。なぜ権力者だけが楽しむのでしょうか?権力を人々に与え、検閲を民主化する時が来ました。自宅のルーターにサイバー主権のスリルをもたらし、プロのようにフィルタリングを始めましょう - あなたもビッグブラザーになることができます。
**ドキュメントウェブサイト: https://gfw.dev/**
Telegram グループ: https://t.me/OpGFW
> [!CAUTION]
> このプロジェクトはまだ開発の初期段階です。使用は自己責任でお願いします。
> [!NOTE]
> 私たちはこのプロジェクト、特により多くのプロトコル用のアナライザーの実装を手伝ってくれるコントリビューターを探しています!!!
> 本プロジェクトはまだ初期開発段階にあります。テスト時のリスクは自己責任でお願いします。私たちは、このプロジェクトを一緒に改善するために貢献者を探しています。
## 特徴
- フル IP/TCP 再アセンブル、各種プロトコルアナライザー
- HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、その他多数
- Shadowsocks の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/)
- トロイの木馬キラー (https://github.com/XTLS/Trojan-killer) に基づくトロイの木馬 (プロキシプロトコル) 検出
- HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、OpenVPN、その他多数
- Shadowsocks、VMess の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/)
- Trojan プロキシプロトコルの検出
- [WIP] 機械学習に基づくトラフィック分類
- IPv4 と IPv6 をフルサポート
- フローベースのマルチコア負荷分散
@@ -29,7 +29,32 @@ Telegram グループ: https://t.me/OpGFW
- ルールのホットリロード (`SIGHUP` を送信してリロード)
- 柔軟なアナライザ&モディファイアフレームワーク
- 拡張可能な IO 実装 (今のところ NFQueue のみ)
- [WIP] ウェブ UI
- リアルタイムのトラフィックダッシュボードとルールエディタを備えたウェブ UI
## ウェブ UI
OpenGFW にはオプションのウェブダッシュボードが同梱されています。リアルタイムのトラフィック統計、
アナライザのプロパティを含むイベントフィード、そしてルールを検証してホットリロードできるルールエディタが利用できます。
スマートフォンでもデスクトップでも使えるレスポンシブ対応です。
ルールは条件のリストとして作成できます。フィールド(SNI、DNS 名、HTTP Host、IP、ポート、検出されたプロトコル)と
条件(ワイルドカード、CIDR、GeoIP の国、GeoSite カテゴリ、範囲、正規表現)、そして 1 つ以上の値を選ぶだけです。
もちろん expr 式や YAML を直接編集することもできます。
設定ファイルで有効にします:
```yaml
web:
enabled: true
listen: :8080
secret: パスワード # 空の場合は起動時にランダム生成されログに出力されます
# cert: /path/to/fullchain.pem
# key: /path/to/privkey.pem
```
UI はバイナリに埋め込まれているため、追加のファイルを配置する必要はありません。
パスワードを知る人はルールを変更できるため、信頼できるネットワークにのみ公開してください。
開発方法は [web/README.md](web/README.md) を参照してください。
## ユースケース
@@ -39,121 +64,3 @@ Telegram グループ: https://t.me/OpGFW
- VPN/プロキシサービスの不正利用防止
- トラフィック分析(ログのみモード)
- 独裁的な野心を実現するのを助ける
## 使用方法
### ビルド
```shell
go build
```
### 実行
```shell
export OPENGFW_LOG_LEVEL=debug
./OpenGFW -c config.yaml rules.yaml
```
#### OpenWrt
OpenGFW は OpenWrt 23.05 で動作することがテストされています(他のバージョンも動作するはずですが、検証されていません)。
依存関係をインストールしてください:
```shell
opkg install kmod-nft-queue kmod-nf-conntrack-netlink
```
### 設定例
```yaml
io:
queueSize: 1024
rcvBuf: 4194304
sndBuf: 4194304
local: true # FORWARD チェーンで OpenGFW を実行したい場合は false に設定する
workers:
count: 4
queueSize: 16
tcpMaxBufferedPagesTotal: 4096
tcpMaxBufferedPagesPerConn: 64
udpMaxStreams: 4096
# 特定のローカルGeoIP / GeoSiteデータベースファイルを読み込むためのパス。
# 設定されていない場合は、https://github.com/LoyalSoldier/v2ray-rules-dat から自動的にダウンロードされます。
# geo:
# geoip: geoip.dat
# geosite: geosite.dat
```
### ルール例
[アナライザーのプロパティ](docs/Analyzers.md)
式言語の構文については、[Expr 言語定義](https://expr-lang.org/docs/language-definition)を参照してください。
```yaml
# ルールは、"action" または "log" の少なくとも一方が設定されていなければなりません。
- name: log horny people
log: true
expr: let sni = string(tls?.req?.sni); sni contains "porn" || sni contains "hentai"
- name: block v2ex http
action: block
expr: string(http?.req?.headers?.host) endsWith "v2ex.com"
- name: block v2ex https
action: block
expr: string(tls?.req?.sni) endsWith "v2ex.com"
- name: block v2ex quic
action: block
expr: string(quic?.req?.sni) endsWith "v2ex.com"
- name: block and log shadowsocks
action: block
log: true
expr: fet != nil && fet.yes
- name: block trojan
action: block
expr: trojan != nil && trojan.yes
- name: v2ex dns poisoning
action: modify
modifier:
name: dns
args:
a: "0.0.0.0"
aaaa: "::"
expr: dns != nil && dns.qr && any(dns.questions, {.name endsWith "v2ex.com"})
- name: block google socks
action: block
expr: string(socks?.req?.addr) endsWith "google.com" && socks?.req?.port == 80
- name: block wireguard by handshake response
action: drop
expr: wireguard?.handshake_response?.receiver_index_matched == true
- name: block bilibili geosite
action: block
expr: geosite(string(tls?.req?.sni), "bilibili")
- name: block CN geoip
action: block
expr: geoip(string(ip.dst), "cn")
- name: block cidr
action: block
expr: cidr(string(ip.dst), "192.168.0.0/16")
```
#### サポートされるアクション
- `allow`: 接続を許可し、それ以上の処理は行わない。
- `block`: 接続をブロックし、それ以上の処理は行わない。
- `drop`: UDP の場合、ルールのトリガーとなったパケットをドロップし、同じフローに含まれる以降のパケットの処理を継続する。TCP の場合は、`block` と同じ。
- `modify`: UDP の場合、与えられた修飾子を使って、ルールをトリガしたパケットを修正し、同じフロー内の今後のパケットを処理し続ける。TCP の場合は、`allow` と同じ。
+34 -129
View File
@@ -1,5 +1,6 @@
# ![OpenGFW](docs/logo.png)
[![Quality check status](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml/badge.svg)](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
[![License][1]][2]
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
@@ -10,21 +11,20 @@
OpenGFW is your very own DIY Great Firewall of China (https://en.wikipedia.org/wiki/Great_Firewall), available as a flexible, easy-to-use open source program on Linux. Why let the powers that be have all the fun? It's time to give power to the people and democratize censorship. Bring the thrill of cyber-sovereignty right into your home router and start filtering like a pro - you too can play Big Brother.
**Documentation site: https://gfw.dev/**
Telegram group: https://t.me/OpGFW
> [!CAUTION]
> This project is still in very early stages of development. Use at your own risk.
> [!NOTE]
> We are looking for contributors to help us with this project, especially implementing analyzers for more protocols!!!
> This project is still in very early stages of development. Use at your own risk. We are looking for contributors to help us improve and expand the project.
## Features
- Full IP/TCP reassembly, various protocol analyzers
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, and many more to come
- "Fully encrypted traffic" detection for Shadowsocks,
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, and many more to come
- "Fully encrypted traffic" detection for Shadowsocks, VMess,
etc. (https://gfw.report/publications/usenixsecurity23/en/)
- Trojan (proxy protocol) detection based on Trojan-killer (https://github.com/XTLS/Trojan-killer)
- Trojan (proxy protocol) detection
- [WIP] Machine learning based traffic classification
- Full IPv4 and IPv6 support
- Flow-based multicore load balancing
@@ -33,7 +33,33 @@ Telegram group: https://t.me/OpGFW
- Hot-reloadable rules (send `SIGHUP` to reload)
- Flexible analyzer & modifier framework
- Extensible IO implementation (only NFQueue for now)
- [WIP] Web UI
- Web UI with live traffic dashboard and rule editor
## Web UI
OpenGFW ships with an optional web dashboard: live traffic statistics, a real-time event
feed with per-stream analyzer properties, and a rule editor that validates and hot reloads
the ruleset. It works on phones as well as on desktops.
Rules can be written as a list of conditions — pick a field (SNI, DNS name, HTTP host,
IP, port, detected protocol), an operator (wildcard, CIDR, GeoIP country, GeoSite
category, range, regex) and one or more values — or as raw expr expressions and YAML.
Countries and site categories are picked from the geo databases the engine loaded.
Enable it in your config file:
```yaml
web:
enabled: true
listen: :8080
secret: your-password-here # generated and logged on startup if left empty
# cert: /path/to/fullchain.pem
# key: /path/to/privkey.pem
```
The UI is embedded in the binary, so no extra files need to be deployed. Only expose it to
trusted networks — anyone with the password can change your rules. See
[web/README.md](web/README.md) for development instructions.
## Use cases
@@ -43,124 +69,3 @@ Telegram group: https://t.me/OpGFW
- Abuse prevention for VPN/proxy services
- Traffic analysis (log only mode)
- Help you fulfill your dictatorial ambitions
## Usage
### Build
```shell
go build
```
### Run
```shell
export OPENGFW_LOG_LEVEL=debug
./OpenGFW -c config.yaml rules.yaml
```
#### OpenWrt
OpenGFW has been tested to work on OpenWrt 23.05 (other versions should also work, just not verified).
Install the dependencies:
```shell
opkg install kmod-nft-queue kmod-nf-conntrack-netlink
```
### Example config
```yaml
io:
queueSize: 1024
rcvBuf: 4194304
sndBuf: 4194304
local: true # set to false if you want to run OpenGFW on FORWARD chain
workers:
count: 4
queueSize: 16
tcpMaxBufferedPagesTotal: 4096
tcpMaxBufferedPagesPerConn: 64
udpMaxStreams: 4096
# The path to load specific local geoip/geosite db files.
# If not set, they will be automatically downloaded from https://github.com/Loyalsoldier/v2ray-rules-dat
# geo:
# geoip: geoip.dat
# geosite: geosite.dat
```
### Example rules
[Analyzer properties](docs/Analyzers.md)
For syntax of the expression language, please refer
to [Expr Language Definition](https://expr-lang.org/docs/language-definition).
```yaml
# A rule must have at least one of "action" or "log" field set.
- name: log horny people
log: true
expr: let sni = string(tls?.req?.sni); sni contains "porn" || sni contains "hentai"
- name: block v2ex http
action: block
expr: string(http?.req?.headers?.host) endsWith "v2ex.com"
- name: block v2ex https
action: block
expr: string(tls?.req?.sni) endsWith "v2ex.com"
- name: block v2ex quic
action: block
expr: string(quic?.req?.sni) endsWith "v2ex.com"
- name: block and log shadowsocks
action: block
log: true
expr: fet != nil && fet.yes
- name: block trojan
action: block
expr: trojan != nil && trojan.yes
- name: v2ex dns poisoning
action: modify
modifier:
name: dns
args:
a: "0.0.0.0"
aaaa: "::"
expr: dns != nil && dns.qr && any(dns.questions, {.name endsWith "v2ex.com"})
- name: block google socks
action: block
expr: string(socks?.req?.addr) endsWith "google.com" && socks?.req?.port == 80
- name: block wireguard by handshake response
action: drop
expr: wireguard?.handshake_response?.receiver_index_matched == true
- name: block bilibili geosite
action: block
expr: geosite(string(tls?.req?.sni), "bilibili")
- name: block CN geoip
action: block
expr: geoip(string(ip.dst), "cn")
- name: block cidr
action: block
expr: cidr(string(ip.dst), "192.168.0.0/16")
```
#### Supported actions
- `allow`: Allow the connection, no further processing.
- `block`: Block the connection, no further processing.
- `drop`: For UDP, drop the packet that triggered the rule, continue processing future packets in the same flow. For
TCP, same as `block`.
- `modify`: For UDP, modify the packet that triggered the rule using the given modifier, continue processing future
packets in the same flow. For TCP, same as `allow`.
+31 -126
View File
@@ -1,5 +1,6 @@
# ![OpenGFW](docs/logo.png)
[![Quality check status](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml/badge.svg)](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
[![License][1]][2]
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
@@ -7,20 +8,19 @@
OpenGFW 是一个 Linux 上灵活、易用、开源的 DIY [GFW](https://zh.wikipedia.org/wiki/%E9%98%B2%E7%81%AB%E9%95%BF%E5%9F%8E) 实现,并且在许多方面比真正的 GFW 更强大。为何让那些掌权者独享乐趣?是时候把权力归还给人民,人人有墙建了。立即安装可以部署在家用路由器上的网络主权 - 你也能是老大哥。
**文档网站: https://gfw.dev/**
Telegram 群组: https://t.me/OpGFW
> [!CAUTION]
> 本项目仍处于早期开发阶段。测试时自行承担风险。
> [!NOTE]
> 我们正在寻求贡献者一起完善本项目,尤其是实现更多协议的解析器!
> 本项目仍处于早期开发阶段。测试时自行承担风险。我们正在寻求贡献者一起完善本项目。
## 功能
- 完整的 IP/TCP 重组,各种协议解析器
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, 更多协议正在开发中
- Shadowsocks 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
- 基于 Trojan-killer 的 Trojan 检测 (https://github.com/XTLS/Trojan-killer)
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, 更多协议正在开发中
- Shadowsocks, VMess 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
- Trojan 协议检测
- [开发中] 基于机器学习的流量分类
- 同等支持 IPv4 和 IPv6
- 基于流的多核负载均衡
@@ -29,7 +29,30 @@ Telegram 群组: https://t.me/OpGFW
- 规则可以热重载 (发送 `SIGHUP` 信号)
- 灵活的协议解析和修改框架
- 可扩展的 IO 实现 (目前只有 NFQueue)
- [开发中] Web UI
- Web UI,包含实时流量面板与规则编辑器
## Web UI
OpenGFW 内置了一个可选的 Web 控制面板:实时流量统计、带解析器属性的实时事件流,以及可以校验并热加载规则的规则编辑器。
界面同时适配手机和桌面浏览器。
规则既可以用条件列表来写——选择字段(SNI、DNS 域名、HTTP Host、IP、端口、识别到的协议)、
条件(通配符、CIDR、GeoIP 国家/地区、GeoSite 分类、端口区间、正则)以及一个或多个值——
也可以直接编写 expr 表达式或 YAML。国家/地区与站点分类直接从引擎加载的 geo 数据库中选择。
在配置文件中启用:
```yaml
web:
enabled: true
listen: :8080
secret: 你的密码 # 留空则启动时随机生成并打印到日志
# cert: /path/to/fullchain.pem
# key: /path/to/privkey.pem
```
前端已经打包进二进制文件,无需额外部署静态资源。请只在可信网络中开放该端口——拿到密码即可修改你的规则。
开发说明见 [web/README.md](web/README.md)。
## 使用场景
@@ -39,121 +62,3 @@ Telegram 群组: https://t.me/OpGFW
- VPN/代理服务滥用防护
- 流量分析 (纯日志模式)
- 助你实现你的独裁野心
## 使用
### 构建
```shell
go build
```
### 运行
```shell
export OPENGFW_LOG_LEVEL=debug
./OpenGFW -c config.yaml rules.yaml
```
#### OpenWrt
OpenGFW 在 OpenWrt 23.05 上测试可用(其他版本应该也可以,暂时未经验证)。
安装依赖:
```shell
opkg install kmod-nft-queue kmod-nf-conntrack-netlink
```
### 样例配置
```yaml
io:
queueSize: 1024
rcvBuf: 4194304
sndBuf: 4194304
local: true # 如果需要在 FORWARD 链上运行 OpenGFW,请设置为 false
workers:
count: 4
queueSize: 16
tcpMaxBufferedPagesTotal: 4096
tcpMaxBufferedPagesPerConn: 64
udpMaxStreams: 4096
# 指定的 geoip/geosite 档案路径
# 如果未设置,将自动从 https://github.com/Loyalsoldier/v2ray-rules-dat 下载
# geo:
# geoip: geoip.dat
# geosite: geosite.dat
```
### 样例规则
[解析器属性](docs/Analyzers.md)
规则的语法请参考 [Expr Language Definition](https://expr-lang.org/docs/language-definition)。
```yaml
# 每条规则必须至少包含 action 或 log 中的一个。
- name: log horny people
log: true
expr: let sni = string(tls?.req?.sni); sni contains "porn" || sni contains "hentai"
- name: block v2ex http
action: block
expr: string(http?.req?.headers?.host) endsWith "v2ex.com"
- name: block v2ex https
action: block
expr: string(tls?.req?.sni) endsWith "v2ex.com"
- name: block v2ex quic
action: block
expr: string(quic?.req?.sni) endsWith "v2ex.com"
- name: block and log shadowsocks
action: block
log: true
expr: fet != nil && fet.yes
- name: block trojan
action: block
expr: trojan != nil && trojan.yes
- name: v2ex dns poisoning
action: modify
modifier:
name: dns
args:
a: "0.0.0.0"
aaaa: "::"
expr: dns != nil && dns.qr && any(dns.questions, {.name endsWith "v2ex.com"})
- name: block google socks
action: block
expr: string(socks?.req?.addr) endsWith "google.com" && socks?.req?.port == 80
- name: block wireguard by handshake response
action: drop
expr: wireguard?.handshake_response?.receiver_index_matched == true
- name: block bilibili geosite
action: block
expr: geosite(string(tls?.req?.sni), "bilibili")
- name: block CN geoip
action: block
expr: geoip(string(ip.dst), "cn")
- name: block cidr
action: block
expr: cidr(string(ip.dst), "192.168.0.0/16")
```
#### 支持的 action
- `allow`: 放行连接,不再处理后续的包。
- `block`: 阻断连接,不再处理后续的包。
- `drop`: 对于 UDP,丢弃触发规则的包,但继续处理同一流中的后续包。对于 TCP,效果同 `block`。
- `modify`: 对于 UDP,用指定的修改器修改触发规则的包,然后继续处理同一流中的后续包。对于 TCP,效果同 `allow`。
+25 -6
View File
@@ -5,7 +5,26 @@ import (
"github.com/apernet/OpenGFW/analyzer/utils"
)
func ParseTLSClientHello(chBuf *utils.ByteBuffer) analyzer.PropMap {
// TLS record types.
const (
RecordTypeHandshake = 0x16
)
// TLS handshake message types.
const (
TypeClientHello = 0x01
TypeServerHello = 0x02
)
// TLS extension numbers.
const (
extServerName = 0x0000
extALPN = 0x0010
extSupportedVersions = 0x002b
extEncryptedClientHello = 0xfe0d
)
func ParseTLSClientHelloMsgData(chBuf *utils.ByteBuffer) analyzer.PropMap {
var ok bool
m := make(analyzer.PropMap)
// Version, random & session ID length combined are within 35 bytes,
@@ -76,7 +95,7 @@ func ParseTLSClientHello(chBuf *utils.ByteBuffer) analyzer.PropMap {
return m
}
func ParseTLSServerHello(shBuf *utils.ByteBuffer) analyzer.PropMap {
func ParseTLSServerHelloMsgData(shBuf *utils.ByteBuffer) analyzer.PropMap {
var ok bool
m := make(analyzer.PropMap)
// Version, random & session ID length combined are within 35 bytes,
@@ -133,7 +152,7 @@ func ParseTLSServerHello(shBuf *utils.ByteBuffer) analyzer.PropMap {
func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer.PropMap) bool {
switch extType {
case 0x0000: // SNI
case extServerName:
ok := extDataBuf.Skip(2) // Ignore list length, we only care about the first entry for now
if !ok {
// Not enough data for list length
@@ -154,7 +173,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
// Not enough data for SNI
return false
}
case 0x0010: // ALPN
case extALPN:
ok := extDataBuf.Skip(2) // Ignore list length, as we read until the end
if !ok {
// Not enough data for list length
@@ -175,7 +194,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
alpnList = append(alpnList, alpn)
}
m["alpn"] = alpnList
case 0x002b: // Supported Versions
case extSupportedVersions:
if extDataBuf.Len() == 2 {
// Server only selects one version
m["supported_versions"], _ = extDataBuf.GetUint16(false, true)
@@ -197,7 +216,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
}
m["supported_versions"] = versions
}
case 0xfe0d: // ECH
case extEncryptedClientHello:
// We can't parse ECH for now, just set a flag
m["ech"] = true
}
+64
View File
@@ -0,0 +1,64 @@
package tcp
import (
"reflect"
"strings"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
func TestHTTPParsing_Request(t *testing.T) {
testCases := map[string]analyzer.PropMap{
"GET / HTTP/1.1\r\n": {
"method": "GET", "path": "/", "version": "HTTP/1.1",
},
"POST /hello?a=1&b=2 HTTP/1.0\r\n": {
"method": "POST", "path": "/hello?a=1&b=2", "version": "HTTP/1.0",
},
"PUT /world HTTP/1.1\r\nContent-Length: 4\r\n\r\nbody": {
"method": "PUT", "path": "/world", "version": "HTTP/1.1", "headers": analyzer.PropMap{"content-length": "4"},
},
"DELETE /goodbye HTTP/2.0\r\n": {
"method": "DELETE", "path": "/goodbye", "version": "HTTP/2.0",
},
}
for tc, want := range testCases {
t.Run(strings.Split(tc, " ")[0], func(t *testing.T) {
tc, want := tc, want
t.Parallel()
u, _ := newHTTPStream(nil).Feed(false, false, false, 0, []byte(tc))
got := u.M.Get("req")
if !reflect.DeepEqual(got, want) {
t.Errorf("\"%s\" parsed = %v, want %v", tc, got, want)
}
})
}
}
func TestHTTPParsing_Response(t *testing.T) {
testCases := map[string]analyzer.PropMap{
"HTTP/1.0 200 OK\r\nContent-Length: 4\r\n\r\nbody": {
"version": "HTTP/1.0", "status": 200,
"headers": analyzer.PropMap{"content-length": "4"},
},
"HTTP/2.0 204 No Content\r\n\r\n": {
"version": "HTTP/2.0", "status": 204,
},
}
for tc, want := range testCases {
t.Run(strings.Split(tc, " ")[0], func(t *testing.T) {
tc, want := tc, want
t.Parallel()
u, _ := newHTTPStream(nil).Feed(true, false, false, 0, []byte(tc))
got := u.M.Get("resp")
if !reflect.DeepEqual(got, want) {
t.Errorf("\"%s\" parsed = %v, want %v", tc, got, want)
}
})
}
}
+2 -2
View File
@@ -208,10 +208,10 @@ func (s *socksStream) parseSocks5ReqMethod() utils.LSMAction {
switch method {
case Socks5AuthNotRequired:
s.authReqMethod = Socks5AuthNotRequired
break
return utils.LSMActionNext
case Socks5AuthPassword:
s.authReqMethod = Socks5AuthPassword
break
return utils.LSMActionNext
default:
// TODO: more auth method to support
}
+84 -36
View File
@@ -44,12 +44,12 @@ type tlsStream struct {
func newTLSStream(logger analyzer.Logger) *tlsStream {
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}}
s.reqLSM = utils.NewLinearStateMachine(
s.tlsClientHelloSanityCheck,
s.parseClientHello,
s.tlsClientHelloPreprocess,
s.parseClientHelloData,
)
s.respLSM = utils.NewLinearStateMachine(
s.tlsServerHelloSanityCheck,
s.parseServerHello,
s.tlsServerHelloPreprocess,
s.parseServerHelloData,
)
return s
}
@@ -89,61 +89,105 @@ func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
return update, cancelled || (s.reqDone && s.respDone)
}
func (s *tlsStream) tlsClientHelloSanityCheck() utils.LSMAction {
data, ok := s.reqBuf.Get(9, true)
// tlsClientHelloPreprocess validates ClientHello message.
//
// During validation, message header and first handshake header may be removed
// from `s.reqBuf`.
func (s *tlsStream) tlsClientHelloPreprocess() utils.LSMAction {
// headers size: content type (1 byte) + legacy protocol version (2 bytes) +
// + content length (2 bytes) + message type (1 byte) +
// + handshake length (3 bytes)
const headersSize = 9
// minimal data size: protocol version (2 bytes) + random (32 bytes) +
// + session ID (1 byte) + cipher suites (4 bytes) +
// + compression methods (2 bytes) + no extensions
const minDataSize = 41
header, ok := s.reqBuf.Get(headersSize, true)
if !ok {
// not a full header yet
return utils.LSMActionPause
}
if data[0] != 0x16 || data[5] != 0x01 {
// Not a TLS handshake, or not a client hello
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeClientHello {
return utils.LSMActionCancel
}
s.clientHelloLen = int(data[6])<<16 | int(data[7])<<8 | int(data[8])
if s.clientHelloLen < 41 {
// 2 (Protocol Version) +
// 32 (Random) +
// 1 (Session ID Length) +
// 2 (Cipher Suites Length) +_ws.col.protocol == "TLSv1.3"
// 2 (Cipher Suite) +
// 1 (Compression Methods Length) +
// 1 (Compression Method) +
// No extensions
// This should be the bare minimum for a client hello
s.clientHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
if s.clientHelloLen < minDataSize {
return utils.LSMActionCancel
}
// TODO: something is missing. See:
// const messageHeaderSize = 4
// fullMessageLen := int(header[3])<<8 | int(header[4])
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
// if msgNo != 1 {
// // what here?
// }
// if messageNo != int(messageNo) {
// // what here?
// }
return utils.LSMActionNext
}
func (s *tlsStream) tlsServerHelloSanityCheck() utils.LSMAction {
data, ok := s.respBuf.Get(9, true)
// tlsServerHelloPreprocess validates ServerHello message.
//
// During validation, message header and first handshake header may be removed
// from `s.reqBuf`.
func (s *tlsStream) tlsServerHelloPreprocess() utils.LSMAction {
// header size: content type (1 byte) + legacy protocol version (2 byte) +
// + content length (2 byte) + message type (1 byte) +
// + handshake length (3 byte)
const headersSize = 9
// minimal data size: server version (2 byte) + random (32 byte) +
// + session ID (>=1 byte) + cipher suite (2 byte) +
// + compression method (1 byte) + no extensions
const minDataSize = 38
header, ok := s.respBuf.Get(headersSize, true)
if !ok {
// not a full header yet
return utils.LSMActionPause
}
if data[0] != 0x16 || data[5] != 0x02 {
// Not a TLS handshake, or not a server hello
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeServerHello {
return utils.LSMActionCancel
}
s.serverHelloLen = int(data[6])<<16 | int(data[7])<<8 | int(data[8])
if s.serverHelloLen < 38 {
// 2 (Protocol Version) +
// 32 (Random) +
// 1 (Session ID Length) +
// 2 (Cipher Suite) +
// 1 (Compression Method) +
// No extensions
// This should be the bare minimum for a server hello
s.serverHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
if s.serverHelloLen < minDataSize {
return utils.LSMActionCancel
}
// TODO: something is missing. See example:
// const messageHeaderSize = 4
// fullMessageLen := int(header[3])<<8 | int(header[4])
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
// if msgNo != 1 {
// // what here?
// }
// if messageNo != int(messageNo) {
// // what here?
// }
return utils.LSMActionNext
}
func (s *tlsStream) parseClientHello() utils.LSMAction {
// parseClientHelloData converts valid ClientHello message data (without
// headers) into `analyzer.PropMap`.
//
// Parsing error may leave `s.reqBuf` in an unusable state.
func (s *tlsStream) parseClientHelloData() utils.LSMAction {
chBuf, ok := s.reqBuf.GetSubBuffer(s.clientHelloLen, true)
if !ok {
// Not a full client hello yet
return utils.LSMActionPause
}
m := internal.ParseTLSClientHello(chBuf)
m := internal.ParseTLSClientHelloMsgData(chBuf)
if m == nil {
return utils.LSMActionCancel
} else {
@@ -153,13 +197,17 @@ func (s *tlsStream) parseClientHello() utils.LSMAction {
}
}
func (s *tlsStream) parseServerHello() utils.LSMAction {
// parseServerHelloData converts valid ServerHello message data (without
// headers) into `analyzer.PropMap`.
//
// Parsing error may leave `s.respBuf` in an unusable state.
func (s *tlsStream) parseServerHelloData() utils.LSMAction {
shBuf, ok := s.respBuf.GetSubBuffer(s.serverHelloLen, true)
if !ok {
// Not a full server hello yet
return utils.LSMActionPause
}
m := internal.ParseTLSServerHello(shBuf)
m := internal.ParseTLSServerHelloMsgData(shBuf)
if m == nil {
return utils.LSMActionCancel
} else {
+69
View File
@@ -0,0 +1,69 @@
package tcp
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
func TestTlsStreamParsing_ClientHello(t *testing.T) {
// example packet taken from <https://tls12.xargs.org/#client-hello/annotated>
clientHello := []byte{
0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8,
0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13,
0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f,
0x00, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00,
0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70,
0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e,
0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00,
0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00,
0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06,
0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12,
0x00, 0x00,
}
want := analyzer.PropMap{
"ciphers": []uint16{52392, 52393, 49199, 49200, 49195, 49196, 49171, 49161, 49172, 49162, 156, 157, 47, 53, 49170, 10},
"compression": []uint8{0},
"random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
"session": []uint8{},
"sni": "example.ulfheim.net",
"version": uint16(771),
}
s := newTLSStream(nil)
u, _ := s.Feed(false, false, false, 0, clientHello)
got := u.M.Get("req")
if !reflect.DeepEqual(got, want) {
t.Errorf("%d B parsed = %v, want %v", len(clientHello), got, want)
}
}
func TestTlsStreamParsing_ServerHello(t *testing.T) {
// example packet taken from <https://tls12.xargs.org/#server-hello/annotated>
serverHello := []byte{
0x16, 0x03, 0x03, 0x00, 0x31, 0x02, 0x00, 0x00, 0x2d, 0x03, 0x03, 0x70,
0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c,
0x7d, 0x7e, 0x7f, 0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88,
0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f, 0x00, 0xc0, 0x13, 0x00, 0x00,
0x05, 0xff, 0x01, 0x00, 0x01, 0x00,
}
want := analyzer.PropMap{
"cipher": uint16(49171),
"compression": uint8(0),
"random": []uint8{112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143},
"session": []uint8{},
"version": uint16(771),
}
s := newTLSStream(nil)
u, _ := s.Feed(true, false, false, 0, serverHello)
got := u.M.Get("resp")
if !reflect.DeepEqual(got, want) {
t.Errorf("%d B parsed = %v, want %v", len(serverHello), got, want)
}
}
+462 -36
View File
@@ -9,22 +9,15 @@ import (
var _ analyzer.TCPAnalyzer = (*TrojanAnalyzer)(nil)
// CCS stands for "Change Cipher Spec"
var trojanCCS = []byte{20, 3, 3, 0, 1, 1}
var ccsPattern = []byte{20, 3, 3, 0, 1, 1}
const (
trojanUpLB = 650
trojanUpUB = 1000
trojanDownLB1 = 170
trojanDownUB1 = 180
trojanDownLB2 = 3000
trojanDownUB2 = 7500
)
// TrojanAnalyzer uses a very simple packet length based check to determine
// if a TLS connection is actually the Trojan proxy protocol.
// The algorithm is from the following project, with small modifications:
// https://github.com/XTLS/Trojan-killer
// Warning: Experimental only. This method is known to have significant false positives and false negatives.
// TrojanAnalyzer uses length-based heuristics to detect Trojan traffic based on
// its "TLS-in-TLS" nature. The heuristics are trained using a decision tree with
// about 20k Trojan samples and 30k non-Trojan samples. The tree is then converted
// to code using a custom tool and inlined here (isTrojanSeq function).
// Accuracy: 1% false positive rate, 10% false negative rate.
// We do NOT recommend directly blocking all positive connections, as this may
// break legitimate TLS connections.
type TrojanAnalyzer struct{}
func (a *TrojanAnalyzer) Name() string {
@@ -32,7 +25,7 @@ func (a *TrojanAnalyzer) Name() string {
}
func (a *TrojanAnalyzer) Limit() int {
return 16384
return 512000
}
func (a *TrojanAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream {
@@ -41,9 +34,11 @@ func (a *TrojanAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) a
type trojanStream struct {
logger analyzer.Logger
active bool
upCount int
downCount int
first bool
count bool
rev bool
seq [4]int
seqIndex int
}
func newTrojanStream(logger analyzer.Logger) *trojanStream {
@@ -57,35 +52,466 @@ func (s *trojanStream) Feed(rev, start, end bool, skip int, data []byte) (u *ana
if len(data) == 0 {
return nil, false
}
if !rev && !s.active && len(data) >= 6 && bytes.Equal(data[:6], trojanCCS) {
// Client CCS encountered, start counting
s.active = true
if s.first {
s.first = false
// Stop if it's not a valid TLS connection
if !(!rev && len(data) >= 3 && data[0] >= 0x16 && data[0] <= 0x17 &&
data[1] == 0x03 && data[2] <= 0x09) {
return nil, true
}
if s.active {
if rev {
// Down direction
s.downCount += len(data)
}
if !rev && !s.count && len(data) >= 6 && bytes.Equal(data[:6], ccsPattern) {
// Client Change Cipher Spec encountered, start counting
s.count = true
}
if s.count {
if rev == s.rev {
// Same direction as last time, just update the number
s.seq[s.seqIndex] += len(data)
} else {
// Up direction
if s.upCount >= trojanUpLB && s.upCount <= trojanUpUB &&
((s.downCount >= trojanDownLB1 && s.downCount <= trojanDownUB1) ||
(s.downCount >= trojanDownLB2 && s.downCount <= trojanDownUB2)) {
// Different direction, bump the index
s.seqIndex += 1
if s.seqIndex == 4 {
return &analyzer.PropUpdate{
Type: analyzer.PropUpdateReplace,
M: analyzer.PropMap{
"up": s.upCount,
"down": s.downCount,
"yes": true,
"seq": s.seq,
"yes": isTrojanSeq(s.seq),
},
}, true
}
s.upCount += len(data)
s.seq[s.seqIndex] += len(data)
s.rev = rev
}
}
// Give up when either direction is over the limit
return nil, s.upCount > trojanUpUB || s.downCount > trojanDownUB2
return nil, false
}
func (s *trojanStream) Close(limited bool) *analyzer.PropUpdate {
return nil
}
func isTrojanSeq(seq [4]int) bool {
length1 := seq[0]
length2 := seq[1]
length3 := seq[2]
length4 := seq[3]
if length2 <= 2431 {
if length2 <= 157 {
if length1 <= 156 {
if length3 <= 108 {
return false
} else {
return false
}
} else {
if length1 <= 892 {
if length3 <= 40 {
return false
} else {
if length3 <= 788 {
if length4 <= 185 {
if length1 <= 411 {
return true
} else {
return false
}
} else {
if length2 <= 112 {
return false
} else {
return true
}
}
} else {
if length3 <= 1346 {
if length1 <= 418 {
return false
} else {
return true
}
} else {
return false
}
}
}
} else {
if length2 <= 120 {
if length2 <= 63 {
return false
} else {
if length4 <= 653 {
return false
} else {
return false
}
}
} else {
return false
}
}
}
} else {
if length1 <= 206 {
if length1 <= 185 {
if length1 <= 171 {
return false
} else {
if length4 <= 211 {
return false
} else {
return false
}
}
} else {
if length2 <= 251 {
return true
} else {
return false
}
}
} else {
if length2 <= 286 {
if length1 <= 1123 {
if length3 <= 70 {
return false
} else {
if length1 <= 659 {
if length3 <= 370 {
return true
} else {
return false
}
} else {
if length4 <= 272 {
return false
} else {
return true
}
}
}
} else {
if length4 <= 537 {
if length2 <= 276 {
if length3 <= 1877 {
return false
} else {
return false
}
} else {
return false
}
} else {
if length1 <= 1466 {
if length1 <= 1435 {
return false
} else {
return true
}
} else {
if length2 <= 193 {
return false
} else {
return false
}
}
}
}
} else {
if length1 <= 284 {
if length1 <= 277 {
if length2 <= 726 {
return false
} else {
if length2 <= 768 {
return true
} else {
return false
}
}
} else {
if length2 <= 782 {
if length4 <= 783 {
return true
} else {
return false
}
} else {
return false
}
}
} else {
if length2 <= 492 {
if length2 <= 396 {
if length2 <= 322 {
return false
} else {
return false
}
} else {
if length4 <= 971 {
return false
} else {
return true
}
}
} else {
if length2 <= 2128 {
if length2 <= 1418 {
return false
} else {
return false
}
} else {
if length3 <= 103 {
return false
} else {
return false
}
}
}
}
}
}
}
} else {
if length2 <= 6232 {
if length3 <= 85 {
if length2 <= 3599 {
return false
} else {
if length1 <= 613 {
return false
} else {
return false
}
}
} else {
if length3 <= 220 {
if length4 <= 1173 {
if length1 <= 874 {
if length4 <= 337 {
if length4 <= 68 {
return true
} else {
return true
}
} else {
if length1 <= 667 {
return true
} else {
return true
}
}
} else {
if length3 <= 108 {
if length1 <= 1930 {
return true
} else {
return true
}
} else {
if length2 <= 5383 {
return false
} else {
return true
}
}
}
} else {
return false
}
} else {
if length1 <= 664 {
if length3 <= 411 {
if length3 <= 383 {
if length4 <= 346 {
return true
} else {
return false
}
} else {
if length1 <= 445 {
return true
} else {
return false
}
}
} else {
if length2 <= 3708 {
if length4 <= 307 {
return true
} else {
return false
}
} else {
if length2 <= 4656 {
return false
} else {
return false
}
}
}
} else {
if length1 <= 1055 {
if length3 <= 580 {
if length1 <= 724 {
return true
} else {
return false
}
} else {
if length1 <= 678 {
return false
} else {
return true
}
}
} else {
if length2 <= 5352 {
if length3 <= 1586 {
return false
} else {
return false
}
} else {
if length4 <= 2173 {
return true
} else {
return false
}
}
}
}
}
}
} else {
if length2 <= 9408 {
if length1 <= 670 {
if length4 <= 76 {
if length3 <= 175 {
return true
} else {
return true
}
} else {
if length2 <= 9072 {
if length3 <= 314 {
if length3 <= 179 {
return false
} else {
return false
}
} else {
if length4 <= 708 {
return false
} else {
return false
}
}
} else {
return true
}
}
} else {
if length1 <= 795 {
if length2 <= 6334 {
if length2 <= 6288 {
return true
} else {
return false
}
} else {
if length4 <= 6404 {
if length2 <= 8194 {
return true
} else {
return true
}
} else {
if length2 <= 8924 {
return false
} else {
return true
}
}
}
} else {
if length3 <= 732 {
if length1 <= 1397 {
if length3 <= 179 {
return false
} else {
return false
}
} else {
if length1 <= 1976 {
return false
} else {
return false
}
}
} else {
if length1 <= 2840 {
if length1 <= 2591 {
return false
} else {
return true
}
} else {
return false
}
}
}
}
} else {
if length4 <= 30 {
return false
} else {
if length2 <= 13314 {
if length4 <= 1786 {
if length2 <= 13018 {
if length4 <= 869 {
return false
} else {
return false
}
} else {
return true
}
} else {
if length3 <= 775 {
return false
} else {
return false
}
}
} else {
if length4 <= 73 {
return false
} else {
if length3 <= 640 {
if length3 <= 237 {
return false
} else {
return false
}
} else {
if length2 <= 43804 {
return false
} else {
return false
}
}
}
}
}
}
}
}
}
+384
View File
@@ -0,0 +1,384 @@
package udp
import (
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/utils"
)
var (
_ analyzer.UDPAnalyzer = (*OpenVPNAnalyzer)(nil)
_ analyzer.TCPAnalyzer = (*OpenVPNAnalyzer)(nil)
)
var (
_ analyzer.UDPStream = (*openvpnUDPStream)(nil)
_ analyzer.TCPStream = (*openvpnTCPStream)(nil)
)
// Ref paper:
// https://www.usenix.org/system/files/sec22fall_xue-diwen.pdf
// OpenVPN Opcodes definitions from:
// https://github.com/OpenVPN/openvpn/blob/master/src/openvpn/ssl_pkt.h
const (
OpenVPNControlHardResetClientV1 = 1
OpenVPNControlHardResetServerV1 = 2
OpenVPNControlSoftResetV1 = 3
OpenVPNControlV1 = 4
OpenVPNAckV1 = 5
OpenVPNDataV1 = 6
OpenVPNControlHardResetClientV2 = 7
OpenVPNControlHardResetServerV2 = 8
OpenVPNDataV2 = 9
OpenVPNControlHardResetClientV3 = 10
OpenVPNControlWkcV1 = 11
)
const (
OpenVPNMinPktLen = 6
OpenVPNTCPPktDefaultLimit = 256
OpenVPNUDPPktDefaultLimit = 256
)
type OpenVPNAnalyzer struct{}
func (a *OpenVPNAnalyzer) Name() string {
return "openvpn"
}
func (a *OpenVPNAnalyzer) Limit() int {
return 0
}
func (a *OpenVPNAnalyzer) NewUDP(info analyzer.UDPInfo, logger analyzer.Logger) analyzer.UDPStream {
return newOpenVPNUDPStream(logger)
}
func (a *OpenVPNAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream {
return newOpenVPNTCPStream(logger)
}
type openvpnPkt struct {
pktLen uint16 // 16 bits, TCP proto only
opcode byte // 5 bits
_keyId byte // 3 bits, not used
// We don't care about the rest of the packet
// payload []byte
}
type openvpnStream struct {
logger analyzer.Logger
reqUpdated bool
reqLSM *utils.LinearStateMachine
reqDone bool
respUpdated bool
respLSM *utils.LinearStateMachine
respDone bool
rxPktCnt int
txPktCnt int
pktLimit int
reqPktParse func() (*openvpnPkt, utils.LSMAction)
respPktParse func() (*openvpnPkt, utils.LSMAction)
lastOpcode byte
}
func (o *openvpnStream) parseCtlHardResetClient() utils.LSMAction {
pkt, action := o.reqPktParse()
if action != utils.LSMActionNext {
return action
}
if pkt.opcode != OpenVPNControlHardResetClientV1 &&
pkt.opcode != OpenVPNControlHardResetClientV2 &&
pkt.opcode != OpenVPNControlHardResetClientV3 {
return utils.LSMActionCancel
}
o.lastOpcode = pkt.opcode
return utils.LSMActionNext
}
func (o *openvpnStream) parseCtlHardResetServer() utils.LSMAction {
if o.lastOpcode != OpenVPNControlHardResetClientV1 &&
o.lastOpcode != OpenVPNControlHardResetClientV2 &&
o.lastOpcode != OpenVPNControlHardResetClientV3 {
return utils.LSMActionCancel
}
pkt, action := o.respPktParse()
if action != utils.LSMActionNext {
return action
}
if pkt.opcode != OpenVPNControlHardResetServerV1 &&
pkt.opcode != OpenVPNControlHardResetServerV2 {
return utils.LSMActionCancel
}
o.lastOpcode = pkt.opcode
return utils.LSMActionNext
}
func (o *openvpnStream) parseReq() utils.LSMAction {
pkt, action := o.reqPktParse()
if action != utils.LSMActionNext {
return action
}
if pkt.opcode != OpenVPNControlSoftResetV1 &&
pkt.opcode != OpenVPNControlV1 &&
pkt.opcode != OpenVPNAckV1 &&
pkt.opcode != OpenVPNDataV1 &&
pkt.opcode != OpenVPNDataV2 &&
pkt.opcode != OpenVPNControlWkcV1 {
return utils.LSMActionCancel
}
o.txPktCnt += 1
o.reqUpdated = true
return utils.LSMActionPause
}
func (o *openvpnStream) parseResp() utils.LSMAction {
pkt, action := o.respPktParse()
if action != utils.LSMActionNext {
return action
}
if pkt.opcode != OpenVPNControlSoftResetV1 &&
pkt.opcode != OpenVPNControlV1 &&
pkt.opcode != OpenVPNAckV1 &&
pkt.opcode != OpenVPNDataV1 &&
pkt.opcode != OpenVPNDataV2 &&
pkt.opcode != OpenVPNControlWkcV1 {
return utils.LSMActionCancel
}
o.rxPktCnt += 1
o.respUpdated = true
return utils.LSMActionPause
}
type openvpnUDPStream struct {
openvpnStream
curPkt []byte
// We don't introduce `invalidCount` here to decrease the false positive rate
// invalidCount int
}
func newOpenVPNUDPStream(logger analyzer.Logger) *openvpnUDPStream {
s := &openvpnUDPStream{
openvpnStream: openvpnStream{
logger: logger,
pktLimit: OpenVPNUDPPktDefaultLimit,
},
}
s.respPktParse = s.parsePkt
s.reqPktParse = s.parsePkt
s.reqLSM = utils.NewLinearStateMachine(
s.parseCtlHardResetClient,
s.parseReq,
)
s.respLSM = utils.NewLinearStateMachine(
s.parseCtlHardResetServer,
s.parseResp,
)
return s
}
func (o *openvpnUDPStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, d bool) {
if len(data) == 0 {
return nil, false
}
var update *analyzer.PropUpdate
var cancelled bool
o.curPkt = data
if rev {
o.respUpdated = false
cancelled, o.respDone = o.respLSM.Run()
if o.respUpdated {
update = &analyzer.PropUpdate{
Type: analyzer.PropUpdateReplace,
M: analyzer.PropMap{"rx_pkt_cnt": o.rxPktCnt, "tx_pkt_cnt": o.txPktCnt},
}
o.respUpdated = false
}
} else {
o.reqUpdated = false
cancelled, o.reqDone = o.reqLSM.Run()
if o.reqUpdated {
update = &analyzer.PropUpdate{
Type: analyzer.PropUpdateReplace,
M: analyzer.PropMap{"rx_pkt_cnt": o.rxPktCnt, "tx_pkt_cnt": o.txPktCnt},
}
o.reqUpdated = false
}
}
return update, cancelled || (o.reqDone && o.respDone) || o.rxPktCnt+o.txPktCnt > o.pktLimit
}
func (o *openvpnUDPStream) Close(limited bool) *analyzer.PropUpdate {
return nil
}
// Parse OpenVPN UDP packet.
func (o *openvpnUDPStream) parsePkt() (p *openvpnPkt, action utils.LSMAction) {
if o.curPkt == nil {
return nil, utils.LSMActionPause
}
if !OpenVPNCheckForValidOpcode(o.curPkt[0] >> 3) {
return nil, utils.LSMActionCancel
}
// Parse packet header
p = &openvpnPkt{}
p.opcode = o.curPkt[0] >> 3
p._keyId = o.curPkt[0] & 0x07
o.curPkt = nil
return p, utils.LSMActionNext
}
type openvpnTCPStream struct {
openvpnStream
reqBuf *utils.ByteBuffer
respBuf *utils.ByteBuffer
}
func newOpenVPNTCPStream(logger analyzer.Logger) *openvpnTCPStream {
s := &openvpnTCPStream{
openvpnStream: openvpnStream{
logger: logger,
pktLimit: OpenVPNTCPPktDefaultLimit,
},
reqBuf: &utils.ByteBuffer{},
respBuf: &utils.ByteBuffer{},
}
s.respPktParse = func() (*openvpnPkt, utils.LSMAction) {
return s.parsePkt(true)
}
s.reqPktParse = func() (*openvpnPkt, utils.LSMAction) {
return s.parsePkt(false)
}
s.reqLSM = utils.NewLinearStateMachine(
s.parseCtlHardResetClient,
s.parseReq,
)
s.respLSM = utils.NewLinearStateMachine(
s.parseCtlHardResetServer,
s.parseResp,
)
return s
}
func (o *openvpnTCPStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, d bool) {
if skip != 0 {
return nil, true
}
if len(data) == 0 {
return nil, false
}
var update *analyzer.PropUpdate
var cancelled bool
if rev {
o.respBuf.Append(data)
o.respUpdated = false
cancelled, o.respDone = o.respLSM.Run()
if o.respUpdated {
update = &analyzer.PropUpdate{
Type: analyzer.PropUpdateReplace,
M: analyzer.PropMap{"rx_pkt_cnt": o.rxPktCnt, "tx_pkt_cnt": o.txPktCnt},
}
o.respUpdated = false
}
} else {
o.reqBuf.Append(data)
o.reqUpdated = false
cancelled, o.reqDone = o.reqLSM.Run()
if o.reqUpdated {
update = &analyzer.PropUpdate{
Type: analyzer.PropUpdateMerge,
M: analyzer.PropMap{"rx_pkt_cnt": o.rxPktCnt, "tx_pkt_cnt": o.txPktCnt},
}
o.reqUpdated = false
}
}
return update, cancelled || (o.reqDone && o.respDone) || o.rxPktCnt+o.txPktCnt > o.pktLimit
}
func (o *openvpnTCPStream) Close(limited bool) *analyzer.PropUpdate {
o.reqBuf.Reset()
o.respBuf.Reset()
return nil
}
// Parse OpenVPN TCP packet.
func (o *openvpnTCPStream) parsePkt(rev bool) (p *openvpnPkt, action utils.LSMAction) {
var buffer *utils.ByteBuffer
if rev {
buffer = o.respBuf
} else {
buffer = o.reqBuf
}
// Parse packet length
pktLen, ok := buffer.GetUint16(false, false)
if !ok {
return nil, utils.LSMActionPause
}
if pktLen < OpenVPNMinPktLen {
return nil, utils.LSMActionCancel
}
pktOp, ok := buffer.Get(3, false)
if !ok {
return nil, utils.LSMActionPause
}
if !OpenVPNCheckForValidOpcode(pktOp[2] >> 3) {
return nil, utils.LSMActionCancel
}
pkt, ok := buffer.Get(int(pktLen)+2, true)
if !ok {
return nil, utils.LSMActionPause
}
pkt = pkt[2:]
// Parse packet header
p = &openvpnPkt{}
p.pktLen = pktLen
p.opcode = pkt[0] >> 3
p._keyId = pkt[0] & 0x07
return p, utils.LSMActionNext
}
func OpenVPNCheckForValidOpcode(opcode byte) bool {
switch opcode {
case OpenVPNControlHardResetClientV1,
OpenVPNControlHardResetServerV1,
OpenVPNControlSoftResetV1,
OpenVPNControlV1,
OpenVPNAckV1,
OpenVPNDataV1,
OpenVPNControlHardResetClientV2,
OpenVPNControlHardResetServerV2,
OpenVPNDataV2,
OpenVPNControlHardResetClientV3,
OpenVPNControlWkcV1:
return true
}
return false
}
+14 -15
View File
@@ -36,41 +36,40 @@ type quicStream struct {
}
func (s *quicStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, done bool) {
// minimal data size: protocol version (2 bytes) + random (32 bytes) +
// + session ID (1 byte) + cipher suites (4 bytes) +
// + compression methods (2 bytes) + no extensions
const minDataSize = 41
if rev {
// We don't support server direction for now
s.invalidCount++
return nil, s.invalidCount >= quicInvalidCountThreshold
}
pl, err := quic.ReadCryptoPayload(data)
if err != nil || len(pl) < 4 {
if err != nil || len(pl) < 4 { // FIXME: isn't length checked inside quic.ReadCryptoPayload? Also, what about error handling?
s.invalidCount++
return nil, s.invalidCount >= quicInvalidCountThreshold
}
// Should be a TLS client hello
if pl[0] != 0x01 {
// Not a client hello
if pl[0] != internal.TypeClientHello {
s.invalidCount++
return nil, s.invalidCount >= quicInvalidCountThreshold
}
chLen := int(pl[1])<<16 | int(pl[2])<<8 | int(pl[3])
if chLen < 41 {
// 2 (Protocol Version) +
// 32 (Random) +
// 1 (Session ID Length) +
// 2 (Cipher Suites Length) +_ws.col.protocol == "TLSv1.3"
// 2 (Cipher Suite) +
// 1 (Compression Methods Length) +
// 1 (Compression Method) +
// No extensions
// This should be the bare minimum for a client hello
if chLen < minDataSize {
s.invalidCount++
return nil, s.invalidCount >= quicInvalidCountThreshold
}
m := internal.ParseTLSClientHello(&utils.ByteBuffer{Buf: pl[4:]})
m := internal.ParseTLSClientHelloMsgData(&utils.ByteBuffer{Buf: pl[4:]})
if m == nil {
s.invalidCount++
return nil, s.invalidCount >= quicInvalidCountThreshold
}
return &analyzer.PropUpdate{
Type: analyzer.PropUpdateMerge,
M: analyzer.PropMap{"req": m},
+58
View File
@@ -0,0 +1,58 @@
package udp
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
func TestQuicStreamParsing_ClientHello(t *testing.T) {
// example packet taken from <https://quic.xargs.org/#client-initial-packet/annotated>
clientHello := make([]byte, 1200)
clientInitial := []byte{
0xcd, 0x00, 0x00, 0x00, 0x01, 0x08, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05,
0x06, 0x07, 0x05, 0x63, 0x5f, 0x63, 0x69, 0x64, 0x00, 0x41, 0x03, 0x98,
0x1c, 0x36, 0xa7, 0xed, 0x78, 0x71, 0x6b, 0xe9, 0x71, 0x1b, 0xa4, 0x98,
0xb7, 0xed, 0x86, 0x84, 0x43, 0xbb, 0x2e, 0x0c, 0x51, 0x4d, 0x4d, 0x84,
0x8e, 0xad, 0xcc, 0x7a, 0x00, 0xd2, 0x5c, 0xe9, 0xf9, 0xaf, 0xa4, 0x83,
0x97, 0x80, 0x88, 0xde, 0x83, 0x6b, 0xe6, 0x8c, 0x0b, 0x32, 0xa2, 0x45,
0x95, 0xd7, 0x81, 0x3e, 0xa5, 0x41, 0x4a, 0x91, 0x99, 0x32, 0x9a, 0x6d,
0x9f, 0x7f, 0x76, 0x0d, 0xd8, 0xbb, 0x24, 0x9b, 0xf3, 0xf5, 0x3d, 0x9a,
0x77, 0xfb, 0xb7, 0xb3, 0x95, 0xb8, 0xd6, 0x6d, 0x78, 0x79, 0xa5, 0x1f,
0xe5, 0x9e, 0xf9, 0x60, 0x1f, 0x79, 0x99, 0x8e, 0xb3, 0x56, 0x8e, 0x1f,
0xdc, 0x78, 0x9f, 0x64, 0x0a, 0xca, 0xb3, 0x85, 0x8a, 0x82, 0xef, 0x29,
0x30, 0xfa, 0x5c, 0xe1, 0x4b, 0x5b, 0x9e, 0xa0, 0xbd, 0xb2, 0x9f, 0x45,
0x72, 0xda, 0x85, 0xaa, 0x3d, 0xef, 0x39, 0xb7, 0xef, 0xaf, 0xff, 0xa0,
0x74, 0xb9, 0x26, 0x70, 0x70, 0xd5, 0x0b, 0x5d, 0x07, 0x84, 0x2e, 0x49,
0xbb, 0xa3, 0xbc, 0x78, 0x7f, 0xf2, 0x95, 0xd6, 0xae, 0x3b, 0x51, 0x43,
0x05, 0xf1, 0x02, 0xaf, 0xe5, 0xa0, 0x47, 0xb3, 0xfb, 0x4c, 0x99, 0xeb,
0x92, 0xa2, 0x74, 0xd2, 0x44, 0xd6, 0x04, 0x92, 0xc0, 0xe2, 0xe6, 0xe2,
0x12, 0xce, 0xf0, 0xf9, 0xe3, 0xf6, 0x2e, 0xfd, 0x09, 0x55, 0xe7, 0x1c,
0x76, 0x8a, 0xa6, 0xbb, 0x3c, 0xd8, 0x0b, 0xbb, 0x37, 0x55, 0xc8, 0xb7,
0xeb, 0xee, 0x32, 0x71, 0x2f, 0x40, 0xf2, 0x24, 0x51, 0x19, 0x48, 0x70,
0x21, 0xb4, 0xb8, 0x4e, 0x15, 0x65, 0xe3, 0xca, 0x31, 0x96, 0x7a, 0xc8,
0x60, 0x4d, 0x40, 0x32, 0x17, 0x0d, 0xec, 0x28, 0x0a, 0xee, 0xfa, 0x09,
0x5d, 0x08, 0xb3, 0xb7, 0x24, 0x1e, 0xf6, 0x64, 0x6a, 0x6c, 0x86, 0xe5,
0xc6, 0x2c, 0xe0, 0x8b, 0xe0, 0x99,
}
copy(clientHello, clientInitial)
want := analyzer.PropMap{
"alpn": []string{"ping/1.0"},
"ciphers": []uint16{4865, 4866, 4867},
"compression": []uint8{0},
"random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
"session": []uint8{},
"sni": "example.ulfheim.net",
"supported_versions": []uint16{772},
"version": uint16(771),
}
s := quicStream{}
u, _ := s.Feed(false, clientHello)
got := u.M.Get("req")
if !reflect.DeepEqual(got, want) {
t.Errorf("%d B parsed = %v, want %v", len(clientHello), got, want)
}
}
+78 -36
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
@@ -17,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"
@@ -37,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
@@ -93,6 +99,7 @@ var analyzers = []analyzer.Analyzer{
&tcp.TLSAnalyzer{},
&tcp.TrojanAnalyzer{},
&udp.DNSAnalyzer{},
&udp.OpenVPNAnalyzer{},
&udp.QUICAnalyzer{},
&udp.WireGuardAnalyzer{},
}
@@ -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 {
@@ -172,6 +180,7 @@ type cliConfigIO struct {
ReadBuffer int `mapstructure:"rcvBuf"`
WriteBuffer int `mapstructure:"sndBuf"`
Local bool `mapstructure:"local"`
RST bool `mapstructure:"rst"`
}
type cliConfigWorkers struct {
@@ -187,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
@@ -198,11 +215,12 @@ func (c *cliConfig) fillIO(config *engine.Config) error {
ReadBuffer: c.IO.ReadBuffer,
WriteBuffer: c.IO.WriteBuffer,
Local: c.IO.Local,
RST: c.IO.RST,
})
if err != nil {
return configError{Field: "io", Err: err}
}
config.IOs = []io.PacketIO{nfio}
config.IO = nfio
return nil
}
@@ -245,24 +263,26 @@ func runMain(cmd *cobra.Command, args []string) {
if err != nil {
logger.Fatal("failed to parse config", zap.Error(err))
}
defer func() {
// Make sure to close all IOs on exit
for _, i := range engineConfig.IOs {
_ = i.Close()
defer engineConfig.IO.Close() // Make sure to close IO on exit
// Statistics hub for the web UI
if config.Web.Enabled {
hub = web.NewHub()
}
}()
// Ruleset
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
if err != nil {
logger.Fatal("failed to load rules", zap.Error(err))
}
rsConfig := &ruleset.BuiltinConfig{
Logger: &rulesetLogger{},
GeoSiteFilename: config.Ruleset.GeoSite,
GeoIpFilename: config.Ruleset.GeoIp,
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))
}
@@ -273,43 +293,40 @@ 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())
go func() {
// Graceful shutdown
shutdownChan := make(chan os.Signal)
signal.Notify(shutdownChan, os.Interrupt, os.Kill)
shutdownChan := make(chan os.Signal, 1)
signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)
<-shutdownChan
logger.Info("shutting down gracefully...")
cancelFunc()
}()
go func() {
// Rule reload
reloadChan := make(chan os.Signal)
reloadChan := make(chan os.Signal, 1)
signal.Notify(reloadChan, syscall.SIGHUP)
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)))
}
@@ -317,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),
@@ -333,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()),
@@ -342,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()),
@@ -351,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),
@@ -359,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()),
@@ -368,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()),
@@ -377,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()),
@@ -408,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),
@@ -417,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),
@@ -431,11 +481,3 @@ func envOrDefaultString(key, def string) string {
}
return def
}
func envOrDefaultBool(key string, def bool) bool {
if v := os.Getenv(key); v != "" {
b, _ := strconv.ParseBool(v)
return b
}
return def
}
+320
View File
@@ -0,0 +1,320 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"runtime"
"sync"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/engine"
"github.com/apernet/OpenGFW/modifier"
"github.com/apernet/OpenGFW/ruleset"
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
"github.com/apernet/OpenGFW/web"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
)
// hub is the (optional) statistics collector feeding the web UI. It is nil
// when the web UI is disabled.
var hub *web.Hub
// ruleManager owns the rule file and knows how to hot reload the engine.
// It is shared between the SIGHUP handler and the web UI.
type ruleManager struct {
path string
analyzers []analyzer.Analyzer
modifiers []modifier.Modifier
rsConfig *ruleset.BuiltinConfig
mu sync.Mutex
engine engine.Engine
}
var _ web.RuleManager = (*ruleManager)(nil)
func newRuleManager(path string, ans []analyzer.Analyzer, mods []modifier.Modifier, rsConfig *ruleset.BuiltinConfig) *ruleManager {
return &ruleManager{path: path, analyzers: ans, modifiers: mods, rsConfig: rsConfig}
}
func (m *ruleManager) SetEngine(en engine.Engine) {
m.mu.Lock()
m.engine = en
m.mu.Unlock()
}
func (m *ruleManager) Path() string { return m.path }
// Compile parses and compiles a rule file content, without applying it.
func (m *ruleManager) Compile(raw string) (ruleset.Ruleset, []ruleset.ExprRule, error) {
rawRs, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
if err != nil {
return nil, nil, fmt.Errorf("failed to parse rules: %w", err)
}
rs, err := ruleset.CompileExprRules(rawRs, m.analyzers, m.modifiers, m.rsConfig)
if err != nil {
return nil, nil, fmt.Errorf("failed to compile rules: %w", err)
}
return rs, rawRs, nil
}
// Reload re-reads the rule file from disk and applies it to the engine.
func (m *ruleManager) Reload() error {
bs, err := os.ReadFile(m.path)
if err != nil {
return err
}
rs, _, err := m.Compile(string(bs))
if err != nil {
return err
}
return m.update(rs)
}
func (m *ruleManager) update(rs ruleset.Ruleset) error {
m.mu.Lock()
en := m.engine
m.mu.Unlock()
if en == nil {
return errors.New("engine is not running")
}
return en.UpdateRuleset(rs)
}
func (m *ruleManager) Load() (string, []web.Rule, error) {
bs, err := os.ReadFile(m.path)
if err != nil {
return "", nil, err
}
rules, err := ruleset.ExprRulesFromYAMLBytes(bs)
if err != nil {
// The file is still shown as-is so that the user can fix it in the editor.
return string(bs), nil, nil
}
return string(bs), toWebRules(rules), nil
}
func (m *ruleManager) Validate(raw string) ([]web.Rule, error) {
_, rules, err := m.Compile(raw)
if err != nil {
return nil, err
}
return toWebRules(rules), nil
}
func (m *ruleManager) Marshal(rules []web.Rule) (string, error) {
out := make([]web.Rule, 0, len(rules))
for _, r := range rules {
if r.Modifier != nil && r.Modifier.Name == "" {
r.Modifier = nil // Leftover from switching a rule away from `modify`
}
out = append(out, r)
}
bs, err := yaml.Marshal(out)
if err != nil {
return "", err
}
return string(bs), nil
}
// Apply compiles the given rules, persists them to the rule file and hot
// reloads the engine. The file is only written once the rules compile.
func (m *ruleManager) Apply(raw string) ([]web.Rule, error) {
rs, rules, err := m.Compile(raw)
if err != nil {
return nil, err
}
if err := m.writeFile(raw); err != nil {
return nil, fmt.Errorf("failed to write rule file: %w", err)
}
if err := m.update(rs); err != nil {
return nil, fmt.Errorf("failed to update ruleset: %w", err)
}
return toWebRules(rules), nil
}
// writeFile replaces the rule file atomically so that a crash in the middle of
// a save cannot leave a truncated ruleset behind.
func (m *ruleManager) writeFile(raw string) error {
mode := os.FileMode(0o644)
if fi, err := os.Stat(m.path); err == nil {
mode = fi.Mode().Perm()
}
dir := filepath.Dir(m.path)
tmp, err := os.CreateTemp(dir, ".rules-*.yaml")
if err != nil {
return err
}
tmpName := tmp.Name()
defer os.Remove(tmpName) // No-op once the rename succeeded
if _, err := tmp.WriteString(raw); err != nil {
tmp.Close()
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmpName, mode); err != nil {
return err
}
return os.Rename(tmpName, m.path)
}
func toWebRules(rules []ruleset.ExprRule) []web.Rule {
out := make([]web.Rule, 0, len(rules))
for _, r := range rules {
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
if r.Modifier.Name != "" {
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
}
out = append(out, wr)
}
return out
}
// startWebServer starts the web UI. It returns nil when the UI is disabled.
func startWebServer(ctx context.Context, config *cliConfig, rm *ruleManager) error {
secret := config.Web.Secret
generated := false
if secret == "" {
secret = web.RandomSecret()
generated = true
}
srv, err := web.NewServer(web.Config{
Listen: config.Web.Listen,
Secret: secret,
CertFile: config.Web.Cert,
KeyFile: config.Web.Key,
Hub: hub,
Rules: rm,
Meta: webMeta(),
Info: func() web.Info { return webInfo(config, rm) },
Geo: func() web.GeoData { return webGeoData(config) },
Logf: func(format string, args ...interface{}) {
logger.Info(fmt.Sprintf(format, args...))
},
})
if err != nil {
return configError{Field: "web", Err: err}
}
scheme := "http"
if srv.TLS() {
scheme = "https"
}
fields := []zap.Field{
zap.String("listen", srv.Addr()),
zap.String("scheme", scheme),
}
if generated {
fields = append(fields, zap.String("password", secret))
logger.Warn("web UI password was not set, using a generated one", fields...)
} else {
logger.Info("web UI started", fields...)
}
go func() {
if err := srv.Run(ctx); err != nil {
logger.Error("web UI stopped", zap.Error(err))
}
}()
return nil
}
func webMeta() web.MetaInfo {
meta := web.MetaInfo{
Actions: []string{"allow", "block", "drop", "modify"},
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
}
for _, a := range analyzers {
proto := "tcp"
if _, ok := a.(analyzer.UDPAnalyzer); ok {
proto = "udp"
}
meta.Analyzers = append(meta.Analyzers, web.AnalyzerInfo{Name: a.Name(), Proto: proto})
}
for _, m := range modifiers {
meta.Modifiers = append(meta.Modifiers, m.Name())
}
return meta
}
func webInfo(config *cliConfig, rm *ruleManager) web.Info {
hostname, _ := os.Hostname()
return web.Info{
Version: appVersion,
Commit: appCommit,
Platform: runtime.GOOS + "/" + runtime.GOARCH,
GoVersion: runtime.Version(),
Hostname: hostname,
RuleFile: rm.Path(),
Config: web.ConfigDigest{
IOQueueSize: config.IO.QueueSize,
IOLocal: config.IO.Local,
IORST: config.IO.RST,
Workers: config.Workers.Count,
WorkerQueue: config.Workers.QueueSize,
UDPMaxStreams: config.Workers.UDPMaxStreams,
GeoIP: config.Ruleset.GeoIp,
GeoSite: config.Ruleset.GeoSite,
},
}
}
// webGeoData lists what the configured geo databases contain, so that the rule
// builder can offer a picker instead of a free text field. Loading the files is
// expensive, but the web server only calls this on demand and caches the result.
func webGeoData(config *cliConfig) web.GeoData {
matcher := geo.NewGeoMatcher(config.Ruleset.GeoSite, config.Ruleset.GeoIp)
var data web.GeoData
if entries, err := matcher.ListGeoIP(); err != nil {
data.IPError = err.Error()
} else {
for _, e := range entries {
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
}
}
if entries, err := matcher.ListGeoSite(); err != nil {
data.SiteError = err.Error()
} else {
for _, e := range entries {
data.Site = append(data.Site, web.GeoEntry{
Code: e.Code,
Count: e.Domains,
Attributes: e.Attributes,
})
}
}
return data
}
// hubInfo converts engine stream info into the shape the hub understands.
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
return web.StreamInfo{
ID: info.ID,
Proto: info.Protocol.String(),
SrcIP: info.SrcIP.String(),
SrcPort: info.SrcPort,
DstIP: info.DstIP.String(),
DstPort: info.DstPort,
Props: toWebProps(info.Props),
}
}
func toWebProps(props analyzer.CombinedPropMap) web.Props {
if len(props) == 0 {
return nil
}
out := make(web.Props, len(props))
for name, p := range props {
out[name] = p
}
return out
}
+27
View File
@@ -0,0 +1,27 @@
io:
queueSize: 1024
queueNum: 100
table: opengfw
connMarkAccept: 1001
connMarkDrop: 1002
rcvBuf: 4194304
sndBuf: 4194304
local: true
rst: false
workers:
count: 4
queueSize: 64
tcpMaxBufferedPagesTotal: 65536
tcpMaxBufferedPagesPerConn: 16
tcpTimeout: 10m
udpMaxStreams: 4096
# 指定的 geoip/geosite 档案路径
# 如果未设置,将自动从 https://github.com/Loyalsoldier/v2ray-rules-dat 下载
# ruleset:
# geoip: geoip.dat
# geosite: geosite.dat
replay:
realtime: false
-418
View File
@@ -1,418 +0,0 @@
# Analyzers
Analyzers are one of the main components of OpenGFW. Their job is to analyze a connection, see if it's a protocol they
support, and if so, extract information from that connection and provide properties for the rule engine to match against
user-provided rules. OpenGFW will automatically analyze which analyzers are referenced in the given rules and enable
only those that are needed.
This document lists the properties provided by each analyzer that can be used by rules.
## DNS (TCP & UDP)
For queries:
```json
{
"dns": {
"aa": false,
"id": 41953,
"opcode": 0,
"qr": false,
"questions": [
{
"class": 1,
"name": "www.google.com",
"type": 1
}
],
"ra": false,
"rcode": 0,
"rd": true,
"tc": false,
"z": 0
}
}
```
For responses:
```json
{
"dns": {
"aa": false,
"answers": [
{
"a": "142.251.32.36",
"class": 1,
"name": "www.google.com",
"ttl": 255,
"type": 1
}
],
"id": 41953,
"opcode": 0,
"qr": true,
"questions": [
{
"class": 1,
"name": "www.google.com",
"type": 1
}
],
"ra": true,
"rcode": 0,
"rd": true,
"tc": false,
"z": 0
}
}
```
Example for blocking DNS queries for `www.google.com`:
```yaml
- name: Block Google DNS
action: drop
expr: dns != nil && !dns.qr && any(dns.questions, {.name == "www.google.com"})
```
## FET (Fully Encrypted Traffic)
Check https://www.usenix.org/system/files/usenixsecurity23-wu-mingshi.pdf for more information.
```json
{
"fet": {
"ex1": 3.7560976,
"ex2": true,
"ex3": 0.9512195,
"ex4": 39,
"ex5": false,
"yes": false
}
}
```
Example for blocking fully encrypted traffic:
```yaml
- name: Block suspicious proxy traffic
action: block
expr: fet != nil && fet.yes
```
## HTTP
```json
{
"http": {
"req": {
"headers": {
"accept": "*/*",
"host": "ipinfo.io",
"user-agent": "curl/7.81.0"
},
"method": "GET",
"path": "/",
"version": "HTTP/1.1"
},
"resp": {
"headers": {
"access-control-allow-origin": "*",
"content-length": "333",
"content-type": "application/json; charset=utf-8",
"date": "Wed, 24 Jan 2024 05:41:44 GMT",
"referrer-policy": "strict-origin-when-cross-origin",
"server": "nginx/1.24.0",
"strict-transport-security": "max-age=2592000; includeSubDomains",
"via": "1.1 google",
"x-content-type-options": "nosniff",
"x-envoy-upstream-service-time": "2",
"x-frame-options": "SAMEORIGIN",
"x-xss-protection": "1; mode=block"
},
"status": 200,
"version": "HTTP/1.1"
}
}
}
```
Example for blocking HTTP requests to `ipinfo.io`:
```yaml
- name: Block ipinfo.io HTTP
action: block
expr: http != nil && http.req != nil && http.req.headers != nil && http.req.headers.host == "ipinfo.io"
```
## SSH
```json
{
"ssh": {
"server": {
"comments": "Ubuntu-3ubuntu0.6",
"protocol": "2.0",
"software": "OpenSSH_8.9p1"
},
"client": {
"comments": "IMHACKER",
"protocol": "2.0",
"software": "OpenSSH_8.9p1"
}
}
}
```
Example for blocking all SSH connections:
```yaml
- name: Block SSH
action: block
expr: ssh != nil
```
## TLS
```json
{
"tls": {
"req": {
"alpn": ["h2", "http/1.1"],
"ciphers": [
4866, 4867, 4865, 49196, 49200, 159, 52393, 52392, 52394, 49195, 49199,
158, 49188, 49192, 107, 49187, 49191, 103, 49162, 49172, 57, 49161,
49171, 51, 157, 156, 61, 60, 53, 47, 255
],
"compression": "AA==",
"random": "UqfPi+EmtMgusILrKcELvVWwpOdPSM/My09nPXl84dg=",
"session": "jCTrpAzHpwrfuYdYx4FEjZwbcQxCuZ52HGIoOcbw1vA=",
"sni": "ipinfo.io",
"supported_versions": [772, 771],
"version": 771,
"ech": true
},
"resp": {
"cipher": 4866,
"compression": 0,
"random": "R/Cy1m9pktuBMZQIHahD8Y83UWPRf8j8luwNQep9yJI=",
"session": "jCTrpAzHpwrfuYdYx4FEjZwbcQxCuZ52HGIoOcbw1vA=",
"supported_versions": 772,
"version": 771
}
}
}
```
Example for blocking TLS connections to `ipinfo.io`:
```yaml
- name: Block ipinfo.io TLS
action: block
expr: tls != nil && tls.req != nil && tls.req.sni == "ipinfo.io"
```
## QUIC
QUIC analyzer produces the same result format as TLS analyzer, but currently only supports "req" direction (client
hello), not "resp" (server hello).
```json
{
"quic": {
"req": {
"alpn": ["h3"],
"ciphers": [4865, 4866, 4867],
"compression": "AA==",
"ech": true,
"random": "FUYLceFReLJl9dRQ0HAus7fi2ZGuKIAApF4keeUqg00=",
"session": "",
"sni": "quic.rocks",
"supported_versions": [772],
"version": 771
}
}
}
```
Example for blocking QUIC connections to `quic.rocks`:
```yaml
- name: Block quic.rocks QUIC
action: block
expr: quic != nil && quic.req != nil && quic.req.sni == "quic.rocks"
```
## Trojan (proxy protocol)
Check https://github.com/XTLS/Trojan-killer for more information.
```json
{
"trojan": {
"down": 4712,
"up": 671,
"yes": true
}
}
```
Example for blocking Trojan connections:
```yaml
- name: Block Trojan
action: block
expr: trojan != nil && trojan.yes
```
## SOCKS
SOCKS4:
```json
{
"socks": {
"version": 4,
"req": {
"cmd": 1,
"addr_type": 1, // same as socks5
"addr": "1.1.1.1",
// for socks4a
// "addr_type": 3,
// "addr": "google.com",
"port": 443,
"auth": {
"user_id": "user"
}
},
"resp": {
"rep": 90, // 0x5A(90) granted
"addr_type": 1,
"addr": "1.1.1.1",
"port": 443
}
}
}
```
SOCKS5 without auth:
```json
{
"socks": {
"version": 5,
"req": {
"cmd": 1, // 0x01: connect, 0x02: bind, 0x03: udp
"addr_type": 3, // 0x01: ipv4, 0x03: domain, 0x04: ipv6
"addr": "google.com",
"port": 80,
"auth": {
"method": 0 // 0x00: no auth, 0x02: username/password
}
},
"resp": {
"rep": 0, // 0x00: success
"addr_type": 1, // 0x01: ipv4, 0x03: domain, 0x04: ipv6
"addr": "198.18.1.31",
"port": 80,
"auth": {
"method": 0 // 0x00: no auth, 0x02: username/password
}
}
}
}
```
SOCKS5 with auth:
```json
{
"socks": {
"version": 5,
"req": {
"cmd": 1, // 0x01: connect, 0x02: bind, 0x03: udp
"addr_type": 3, // 0x01: ipv4, 0x03: domain, 0x04: ipv6
"addr": "google.com",
"port": 80,
"auth": {
"method": 2, // 0x00: no auth, 0x02: username/password
"username": "user",
"password": "pass"
}
},
"resp": {
"rep": 0, // 0x00: success
"addr_type": 1, // 0x01: ipv4, 0x03: domain, 0x04: ipv6
"addr": "198.18.1.31",
"port": 80,
"auth": {
"method": 2, // 0x00: no auth, 0x02: username/password
"status": 0 // 0x00: success, 0x01: failure
}
}
}
}
```
Example for blocking connections to `google.com:80` and user `foobar`:
```yaml
- name: Block SOCKS google.com:80
action: block
expr: string(socks?.req?.addr) endsWith "google.com" && socks?.req?.port == 80
- name: Block SOCKS user foobar
action: block
expr: socks?.req?.auth?.method == 2 && socks?.req?.auth?.username == "foobar"
```
## WireGuard
```json
{
"wireguard": {
"message_type": 1, // 0x1: handshake_initiation, 0x2: handshake_response, 0x3: packet_cookie_reply, 0x4: packet_data
"handshake_initiation": {
"sender_index": 0x12345678
},
"handshake_response": {
"sender_index": 0x12345678,
"receiver_index": 0x87654321,
"receiver_index_matched": true
},
"packet_data": {
"receiver_index": 0x12345678,
"receiver_index_matched": true
},
"packet_cookie_reply": {
"receiver_index": 0x12345678,
"receiver_index_matched": true
}
}
}
```
Example for blocking WireGuard traffic:
```yaml
# false positive: high
- name: Block all WireGuard-like traffic
action: block
expr: wireguard != nil
# false positive: medium
- name: Block WireGuard by handshake_initiation
action: drop
expr: wireguard?.handshake_initiation != nil
# false positive: low
- name: Block WireGuard by handshake_response
action: drop
expr: wireguard?.handshake_response?.receiver_index_matched == true
# false positive: pretty low
- name: Block WireGuard by packet_data
action: block
expr: wireguard?.packet_data?.receiver_index_matched == true
```
+10 -14
View File
@@ -15,7 +15,7 @@ var _ Engine = (*engine)(nil)
type engine struct {
logger Logger
ioList []io.PacketIO
io io.PacketIO
workers []*worker
}
@@ -42,7 +42,7 @@ func NewEngine(config Config) (Engine, error) {
}
return &engine{
logger: config.Logger,
ioList: config.IOs,
io: config.IO,
workers: workers,
}, nil
}
@@ -58,28 +58,25 @@ func (e *engine) UpdateRuleset(r ruleset.Ruleset) error {
func (e *engine) Run(ctx context.Context) error {
ioCtx, ioCancel := context.WithCancel(ctx)
defer ioCancel() // Stop workers & IOs
defer ioCancel() // Stop workers & IO
// Start workers
for _, w := range e.workers {
go w.Run(ioCtx)
}
// Register callbacks
errChan := make(chan error, len(e.ioList))
for _, i := range e.ioList {
ioEntry := i // Make sure dispatch() uses the correct ioEntry
err := ioEntry.Register(ioCtx, func(p io.Packet, err error) bool {
// Register IO callback
errChan := make(chan error, 1)
err := e.io.Register(ioCtx, func(p io.Packet, err error) bool {
if err != nil {
errChan <- err
return false
}
return e.dispatch(ioEntry, p)
return e.dispatch(p)
})
if err != nil {
return err
}
}
// Block until IO errors or context is cancelled
select {
@@ -91,8 +88,7 @@ func (e *engine) Run(ctx context.Context) error {
}
// dispatch dispatches a packet to a worker.
// This must be safe for concurrent use, as it may be called from multiple IOs.
func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
func (e *engine) dispatch(p io.Packet) bool {
data := p.Data()
ipVersion := data[0] >> 4
var layerType gopacket.LayerType
@@ -102,7 +98,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
layerType = layers.LayerTypeIPv6
} else {
// Unsupported network layer
_ = ioEntry.SetVerdict(p, io.VerdictAcceptStream, nil)
_ = e.io.SetVerdict(p, io.VerdictAcceptStream, nil)
return true
}
// Load balance by stream ID
@@ -112,7 +108,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
StreamID: p.StreamID(),
Packet: packet,
SetVerdict: func(v io.Verdict, b []byte) error {
return ioEntry.SetVerdict(p, v, b)
return e.io.SetVerdict(p, v, b)
},
})
return true
+1 -1
View File
@@ -18,7 +18,7 @@ type Engine interface {
// Config is the configuration for the engine.
type Config struct {
Logger Logger
IOs []io.PacketIO
IO io.PacketIO
Ruleset ruleset.Ruleset
Workers int // Number of workers. Zero or negative means auto (number of CPU cores).
+1 -4
View File
@@ -5,7 +5,7 @@ go 1.21
require (
github.com/bwmarrin/snowflake v0.3.0
github.com/coreos/go-iptables v0.7.0
github.com/expr-lang/expr v1.15.7
github.com/expr-lang/expr v1.16.3
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866
github.com/hashicorp/golang-lru/v2 v2.0.7
@@ -13,7 +13,6 @@ require (
github.com/quic-go/quic-go v0.41.0
github.com/spf13/cobra v1.8.0
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.8.4
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.19.0
golang.org/x/sys v0.17.0
@@ -22,7 +21,6 @@ require (
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/go-cmp v0.5.9 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
@@ -32,7 +30,6 @@ require (
github.com/mdlayher/socket v0.1.1 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
+2 -2
View File
@@ -7,8 +7,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/expr-lang/expr v1.15.7 h1:BK0JcWUkoW6nrbLBo6xCKhz4BvH5DSOOu1Gx5lucyZo=
github.com/expr-lang/expr v1.15.7/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/expr-lang/expr v1.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to=
github.com/expr-lang/expr v1.16.3/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf h1:NqGS3vTHzVENbIfd87cXZwdpO6MB2R1PjHMJLi4Z3ow=
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+5 -1
View File
@@ -2,6 +2,7 @@ package io
import (
"context"
"net"
)
type Verdict int
@@ -29,7 +30,6 @@ type Packet interface {
// PacketCallback is called for each packet received.
// Return false to "unregister" and stop receiving packets.
// It must be safe for concurrent use.
type PacketCallback func(Packet, error) bool
type PacketIO interface {
@@ -39,6 +39,10 @@ type PacketIO interface {
Register(context.Context, PacketCallback) error
// SetVerdict sets the verdict for a packet.
SetVerdict(Packet, Verdict, []byte) error
// ProtectedDialContext is like net.DialContext, but the connection is "protected"
// in the sense that the packets sent/received through the connection must bypass
// the packet IO and not be processed by the callback.
ProtectedDialContext(ctx context.Context, network, address string) (net.Conn, error)
// Close closes the packet IO.
Close() error
}
+124 -66
View File
@@ -5,9 +5,11 @@ import (
"encoding/binary"
"errors"
"fmt"
"net"
"os/exec"
"strconv"
"strings"
"syscall"
"github.com/coreos/go-iptables/iptables"
"github.com/florianl/go-nfqueue"
@@ -27,59 +29,63 @@ const (
nftTable = "opengfw"
)
var nftRulesForward = fmt.Sprintf(`
define ACCEPT_CTMARK=%d
define DROP_CTMARK=%d
define QUEUE_NUM=%d
table %s %s {
chain FORWARD {
type filter hook forward priority filter; policy accept;
ct mark $ACCEPT_CTMARK counter accept
ct mark $DROP_CTMARK counter drop
counter queue num $QUEUE_NUM bypass
func generateNftRules(local, rst bool) (*nftTableSpec, error) {
if local && rst {
return nil, errors.New("tcp rst is not supported in local mode")
}
}
`, nfqueueConnMarkAccept, nfqueueConnMarkDrop, nfqueueNum, nftFamily, nftTable)
var nftRulesLocal = fmt.Sprintf(`
define ACCEPT_CTMARK=%d
define DROP_CTMARK=%d
define QUEUE_NUM=%d
table %s %s {
chain INPUT {
type filter hook input priority filter; policy accept;
ct mark $ACCEPT_CTMARK counter accept
ct mark $DROP_CTMARK counter drop
counter queue num $QUEUE_NUM bypass
table := &nftTableSpec{
Family: nftFamily,
Table: nftTable,
}
chain OUTPUT {
type filter hook output priority filter; policy accept;
ct mark $ACCEPT_CTMARK counter accept
ct mark $DROP_CTMARK counter drop
counter queue num $QUEUE_NUM bypass
table.Defines = append(table.Defines, fmt.Sprintf("define ACCEPT_CTMARK=%d", nfqueueConnMarkAccept))
table.Defines = append(table.Defines, fmt.Sprintf("define DROP_CTMARK=%d", nfqueueConnMarkDrop))
table.Defines = append(table.Defines, fmt.Sprintf("define QUEUE_NUM=%d", nfqueueNum))
if local {
table.Chains = []nftChainSpec{
{Chain: "INPUT", Header: "type filter hook input priority filter; policy accept;"},
{Chain: "OUTPUT", Header: "type filter hook output priority filter; policy accept;"},
}
}
`, nfqueueConnMarkAccept, nfqueueConnMarkDrop, nfqueueNum, nftFamily, nftTable)
var iptRulesForward = []iptRule{
{"filter", "FORWARD", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}},
{"filter", "FORWARD", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}},
{"filter", "FORWARD", []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}},
} else {
table.Chains = []nftChainSpec{
{Chain: "FORWARD", Header: "type filter hook forward priority filter; policy accept;"},
}
}
for i := range table.Chains {
c := &table.Chains[i]
c.Rules = append(c.Rules, "meta mark $ACCEPT_CTMARK ct mark set $ACCEPT_CTMARK") // Bypass protected connections
c.Rules = append(c.Rules, "ct mark $ACCEPT_CTMARK counter accept")
if rst {
c.Rules = append(c.Rules, "ip protocol tcp ct mark $DROP_CTMARK counter reject with tcp reset")
}
c.Rules = append(c.Rules, "ct mark $DROP_CTMARK counter drop")
c.Rules = append(c.Rules, "counter queue num $QUEUE_NUM bypass")
}
return table, nil
}
var iptRulesLocal = []iptRule{
{"filter", "INPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}},
{"filter", "INPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}},
{"filter", "INPUT", []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}},
func generateIptRules(local, rst bool) ([]iptRule, error) {
if local && rst {
return nil, errors.New("tcp rst is not supported in local mode")
}
var chains []string
if local {
chains = []string{"INPUT", "OUTPUT"}
} else {
chains = []string{"FORWARD"}
}
rules := make([]iptRule, 0, 4*len(chains))
for _, chain := range chains {
// Bypass protected connections
rules = append(rules, iptRule{"filter", chain, []string{"-m", "mark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "CONNMARK", "--set-mark", strconv.Itoa(nfqueueConnMarkAccept)}})
rules = append(rules, iptRule{"filter", chain, []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}})
if rst {
rules = append(rules, iptRule{"filter", chain, []string{"-p", "tcp", "-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "REJECT", "--reject-with", "tcp-reset"}})
}
rules = append(rules, iptRule{"filter", chain, []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}})
rules = append(rules, iptRule{"filter", chain, []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}})
}
{"filter", "OUTPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}},
{"filter", "OUTPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}},
{"filter", "OUTPUT", []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}},
return rules, nil
}
var _ PacketIO = (*nfqueuePacketIO)(nil)
@@ -89,11 +95,14 @@ var errNotNFQueuePacket = errors.New("not an NFQueue packet")
type nfqueuePacketIO struct {
n *nfqueue.Nfqueue
local bool
rst bool
rSet bool // whether the nftables/iptables rules have been set
// iptables not nil = use iptables instead of nftables
ipt4 *iptables.IPTables
ipt6 *iptables.IPTables
protectedDialer *net.Dialer
}
type NFQueuePacketIOConfig struct {
@@ -101,6 +110,7 @@ type NFQueuePacketIOConfig struct {
ReadBuffer int
WriteBuffer int
Local bool
RST bool
}
func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
@@ -147,8 +157,21 @@ func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
return &nfqueuePacketIO{
n: n,
local: config.Local,
rst: config.RST,
ipt4: ipt4,
ipt6: ipt6,
protectedDialer: &net.Dialer{
Control: func(network, address string, c syscall.RawConn) error {
var err error
cErr := c.Control(func(fd uintptr) {
err = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_MARK, nfqueueConnMarkAccept)
})
if cErr != nil {
return cErr
}
return err
},
},
}, nil
}
@@ -182,9 +205,9 @@ func (n *nfqueuePacketIO) Register(ctx context.Context, cb PacketCallback) error
}
if !n.rSet {
if n.ipt4 != nil {
err = n.setupIpt(n.local, false)
err = n.setupIpt(n.local, n.rst, false)
} else {
err = n.setupNft(n.local, false)
err = n.setupNft(n.local, n.rst, false)
}
if err != nil {
return err
@@ -235,32 +258,34 @@ func (n *nfqueuePacketIO) SetVerdict(p Packet, v Verdict, newPacket []byte) erro
}
}
func (n *nfqueuePacketIO) ProtectedDialContext(ctx context.Context, network, address string) (net.Conn, error) {
return n.protectedDialer.DialContext(ctx, network, address)
}
func (n *nfqueuePacketIO) Close() error {
if n.rSet {
if n.ipt4 != nil {
_ = n.setupIpt(n.local, true)
_ = n.setupIpt(n.local, n.rst, true)
} else {
_ = n.setupNft(n.local, true)
_ = n.setupNft(n.local, n.rst, true)
}
n.rSet = false
}
return n.n.Close()
}
func (n *nfqueuePacketIO) setupNft(local, remove bool) error {
var rules string
if local {
rules = nftRulesLocal
} else {
rules = nftRulesForward
func (n *nfqueuePacketIO) setupNft(local, rst, remove bool) error {
rules, err := generateNftRules(local, rst)
if err != nil {
return err
}
var err error
rulesText := rules.String()
if remove {
err = nftDelete(nftFamily, nftTable)
} else {
// Delete first to make sure no leftover rules
_ = nftDelete(nftFamily, nftTable)
err = nftAdd(rules)
err = nftAdd(rulesText)
}
if err != nil {
return err
@@ -268,14 +293,11 @@ func (n *nfqueuePacketIO) setupNft(local, remove bool) error {
return nil
}
func (n *nfqueuePacketIO) setupIpt(local, remove bool) error {
var rules []iptRule
if local {
rules = iptRulesLocal
} else {
rules = iptRulesForward
func (n *nfqueuePacketIO) setupIpt(local, rst, remove bool) error {
rules, err := generateIptRules(local, rst)
if err != nil {
return err
}
var err error
if remove {
err = iptsBatchDeleteIfExists([]*iptables.IPTables{n.ipt4, n.ipt6}, rules)
} else {
@@ -330,6 +352,42 @@ func nftDelete(family, table string) error {
return cmd.Run()
}
type nftTableSpec struct {
Defines []string
Family, Table string
Chains []nftChainSpec
}
func (t *nftTableSpec) String() string {
chains := make([]string, 0, len(t.Chains))
for _, c := range t.Chains {
chains = append(chains, c.String())
}
return fmt.Sprintf(`
%s
table %s %s {
%s
}
`, strings.Join(t.Defines, "\n"), t.Family, t.Table, strings.Join(chains, ""))
}
type nftChainSpec struct {
Chain string
Header string
Rules []string
}
func (c *nftChainSpec) String() string {
return fmt.Sprintf(`
chain %s {
%s
%s
}
`, c.Chain, c.Header, strings.Join(c.Rules, "\n\x20\x20\x20\x20"))
}
type iptRule struct {
Table, Chain string
RuleSpec []string
View File
+1 -1
View File
@@ -49,7 +49,7 @@ func (l *V2GeoLoader) shouldDownload(filename string) bool {
if os.IsNotExist(err) {
return true
}
dt := time.Now().Sub(info.ModTime())
dt := time.Since(info.ModTime())
if l.UpdateInterval == 0 {
return dt > geoDefaultUpdateInterval
} else {
+67 -5
View File
@@ -2,6 +2,7 @@ package geo
import (
"net"
"sort"
"strings"
"sync"
)
@@ -14,14 +15,12 @@ type GeoMatcher struct {
ipMatcherLock sync.Mutex
}
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) (*GeoMatcher, error) {
geoLoader := NewDefaultGeoLoader(geoSiteFilename, geoIpFilename)
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) *GeoMatcher {
return &GeoMatcher{
geoLoader: geoLoader,
geoLoader: NewDefaultGeoLoader(geoSiteFilename, geoIpFilename),
geoSiteMatcher: make(map[string]hostMatcher),
geoIpMatcher: make(map[string]hostMatcher),
}, nil
}
}
func (g *GeoMatcher) MatchGeoIp(ip, condition string) bool {
@@ -99,6 +98,69 @@ func (g *GeoMatcher) LoadGeoSite() error {
return err
}
// GeoIPEntry describes one entry of a GeoIP database.
type GeoIPEntry struct {
// Code is the lowercase key to pass to geoip(), usually a country code.
Code string
// CIDRs is the number of networks the entry covers.
CIDRs int
}
// GeoSiteEntry describes one entry of a GeoSite database.
type GeoSiteEntry struct {
// Code is the lowercase key to pass to geosite().
Code string
// Attributes are the suffixes usable as `code@attribute`.
Attributes []string
// Domains is the number of domain rules the entry covers.
Domains int
}
// ListGeoIP returns every entry of the GeoIP database, sorted by code.
// It is meant for UIs that let the user pick a country instead of typing one.
func (g *GeoMatcher) ListGeoIP() ([]GeoIPEntry, error) {
gMap, err := g.geoLoader.LoadGeoIP()
if err != nil {
return nil, err
}
entries := make([]GeoIPEntry, 0, len(gMap))
for code, list := range gMap {
entries = append(entries, GeoIPEntry{Code: code, CIDRs: len(list.GetCidr())})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
return entries, nil
}
// ListGeoSite returns every entry of the GeoSite database, sorted by code,
// including the attributes each entry supports.
func (g *GeoMatcher) ListGeoSite() ([]GeoSiteEntry, error) {
gMap, err := g.geoLoader.LoadGeoSite()
if err != nil {
return nil, err
}
entries := make([]GeoSiteEntry, 0, len(gMap))
for code, list := range gMap {
attrSet := make(map[string]struct{})
for _, domain := range list.GetDomain() {
for _, attr := range domain.GetAttribute() {
attrSet[strings.ToLower(attr.GetKey())] = struct{}{}
}
}
attrs := make([]string, 0, len(attrSet))
for attr := range attrSet {
attrs = append(attrs, attr)
}
sort.Strings(attrs)
entries = append(entries, GeoSiteEntry{
Code: code,
Attributes: attrs,
Domains: len(list.GetDomain()),
})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Code < entries[j].Code })
return entries, nil
}
func (g *GeoMatcher) LoadGeoIP() error {
_, err := g.geoLoader.LoadGeoIP()
return err
-54
View File
@@ -1,54 +0,0 @@
package v2geo
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLoadGeoIP(t *testing.T) {
m, err := LoadGeoIP("geoip.dat")
assert.NoError(t, err)
// Exact checks since we know the data.
assert.Len(t, m, 252)
assert.Equal(t, m["cn"].CountryCode, "CN")
assert.Len(t, m["cn"].Cidr, 10407)
assert.Equal(t, m["us"].CountryCode, "US")
assert.Len(t, m["us"].Cidr, 193171)
assert.Equal(t, m["private"].CountryCode, "PRIVATE")
assert.Len(t, m["private"].Cidr, 18)
assert.Contains(t, m["private"].Cidr, &CIDR{
Ip: []byte("\xc0\xa8\x00\x00"),
Prefix: 16,
})
}
func TestLoadGeoSite(t *testing.T) {
m, err := LoadGeoSite("geosite.dat")
assert.NoError(t, err)
// Exact checks since we know the data.
assert.Len(t, m, 1204)
assert.Equal(t, m["netflix"].CountryCode, "NETFLIX")
assert.Len(t, m["netflix"].Domain, 25)
assert.Contains(t, m["netflix"].Domain, &Domain{
Type: Domain_Full,
Value: "netflix.com.edgesuite.net",
})
assert.Contains(t, m["netflix"].Domain, &Domain{
Type: Domain_RootDomain,
Value: "fast.com",
})
assert.Len(t, m["google"].Domain, 1066)
assert.Contains(t, m["google"].Domain, &Domain{
Type: Domain_RootDomain,
Value: "ggpht.cn",
Attribute: []*Domain_Attribute{
{
Key: "cn",
TypedValue: &Domain_Attribute_BoolValue{BoolValue: true},
},
},
})
}
+116 -59
View File
@@ -1,11 +1,15 @@
package ruleset
import (
"context"
"fmt"
"net"
"os"
"reflect"
"strings"
"time"
"github.com/expr-lang/expr/builtin"
"github.com/expr-lang/expr"
"github.com/expr-lang/expr/ast"
@@ -38,8 +42,13 @@ func ExprRulesFromYAML(file string) ([]ExprRule, error) {
if err != nil {
return nil, err
}
return ExprRulesFromYAMLBytes(bs)
}
// ExprRulesFromYAMLBytes parses expression rules from a YAML document in memory.
func ExprRulesFromYAMLBytes(bs []byte) ([]ExprRule, error) {
var rules []ExprRule
err = yaml.Unmarshal(bs, &rules)
err := yaml.Unmarshal(bs, &rules)
return rules, err
}
@@ -58,7 +67,6 @@ type exprRuleset struct {
Rules []compiledExprRule
Ans []analyzer.Analyzer
Logger Logger
GeoMatcher *geo.GeoMatcher
}
func (r *exprRuleset) Analyzers(info StreamInfo) []analyzer.Analyzer {
@@ -100,10 +108,7 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
fullAnMap := analyzersToMap(ans)
fullModMap := modifiersToMap(mods)
depAnMap := make(map[string]analyzer.Analyzer)
geoMatcher, err := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
if err != nil {
return nil, err
}
funcMap := buildFunctionMap(config)
// Compile all rules and build a map of analyzers that are used by the rules.
for _, rule := range rules {
if rule.Action == "" && !rule.Log {
@@ -118,13 +123,19 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
action = &a
}
visitor := &idVisitor{Variables: make(map[string]bool), Identifiers: make(map[string]bool)}
patcher := &idPatcher{}
patcher := &idPatcher{FuncMap: funcMap}
program, err := expr.Compile(rule.Expr,
func(c *conf.Config) {
c.Strict = false
c.Expect = reflect.Bool
c.Visitors = append(c.Visitors, visitor, patcher)
registerBuiltinFunctions(c.Functions, geoMatcher)
for name, f := range funcMap {
c.Functions[name] = &builtin.Function{
Name: name,
Func: f.Func,
Types: f.Types,
}
}
},
)
if err != nil {
@@ -138,24 +149,15 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
if isBuiltInAnalyzer(name) || visitor.Variables[name] {
continue
}
// Check if it's one of the built-in functions, and if so,
// skip it as an analyzer & do initialization if necessary.
switch name {
case "geoip":
if err := geoMatcher.LoadGeoIP(); err != nil {
return nil, fmt.Errorf("rule %q failed to load geoip: %w", rule.Name, err)
if f, ok := funcMap[name]; ok {
// Built-in function, initialize if necessary
if f.InitFunc != nil {
if err := f.InitFunc(); err != nil {
return nil, fmt.Errorf("rule %q failed to initialize function %q: %w", rule.Name, name, err)
}
case "geosite":
if err := geoMatcher.LoadGeoSite(); err != nil {
return nil, fmt.Errorf("rule %q failed to load geosite: %w", rule.Name, err)
}
case "cidr":
// No initialization needed for CIDR.
default:
a, ok := fullAnMap[name]
if !ok {
return nil, fmt.Errorf("rule %q uses unknown analyzer %q", rule.Name, name)
}
} else if a, ok := fullAnMap[name]; ok {
// Analyzer, add to dependency map
depAnMap[name] = a
}
}
@@ -187,34 +189,9 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
Rules: compiledRules,
Ans: depAns,
Logger: config.Logger,
GeoMatcher: geoMatcher,
}, nil
}
func registerBuiltinFunctions(funcMap map[string]*ast.Function, geoMatcher *geo.GeoMatcher) {
funcMap["geoip"] = &ast.Function{
Name: "geoip",
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
}
funcMap["geosite"] = &ast.Function{
Name: "geosite",
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
}
funcMap["cidr"] = &ast.Function{
Name: "cidr",
Func: func(params ...any) (any, error) {
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
},
Types: []reflect.Type{reflect.TypeOf((func(string, string) bool)(nil)), reflect.TypeOf(builtins.MatchCIDR)},
}
}
func streamInfoToExprEnv(info StreamInfo) map[string]interface{} {
m := map[string]interface{}{
"id": info.ID,
@@ -299,6 +276,7 @@ func (v *idVisitor) Visit(node *ast.Node) {
// idPatcher patches the AST during expr compilation, replacing certain values with
// their internal representations for better runtime performance.
type idPatcher struct {
FuncMap map[string]*Function
Err error
}
@@ -306,22 +284,101 @@ func (p *idPatcher) Visit(node *ast.Node) {
switch (*node).(type) {
case *ast.CallNode:
callNode := (*node).(*ast.CallNode)
if callNode.Func == nil {
if callNode.Callee == nil {
// Ignore invalid call nodes
return
}
switch callNode.Func.Name {
case "cidr":
cidrStringNode, ok := callNode.Arguments[1].(*ast.StringNode)
if !ok {
return
}
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
if err != nil {
if f, ok := p.FuncMap[callNode.Callee.String()]; ok {
if f.PatchFunc != nil {
if err := f.PatchFunc(&callNode.Arguments); err != nil {
p.Err = err
return
}
callNode.Arguments[1] = &ast.ConstantNode{Value: cidr}
}
}
}
}
type Function struct {
InitFunc func() error
PatchFunc func(args *[]ast.Node) error
Func func(params ...any) (any, error)
Types []reflect.Type
}
func buildFunctionMap(config *BuiltinConfig) map[string]*Function {
geoMatcher := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
return map[string]*Function{
"geoip": {
InitFunc: geoMatcher.LoadGeoIP,
PatchFunc: nil,
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
},
"geosite": {
InitFunc: geoMatcher.LoadGeoSite,
PatchFunc: nil,
Func: func(params ...any) (any, error) {
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
},
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
},
"cidr": {
InitFunc: nil,
PatchFunc: func(args *[]ast.Node) error {
cidrStringNode, ok := (*args)[1].(*ast.StringNode)
if !ok {
return fmt.Errorf("cidr: invalid argument type")
}
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
if err != nil {
return err
}
(*args)[1] = &ast.ConstantNode{Value: cidr}
return nil
},
Func: func(params ...any) (any, error) {
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
},
Types: []reflect.Type{reflect.TypeOf(builtins.MatchCIDR)},
},
"lookup": {
InitFunc: nil,
PatchFunc: func(args *[]ast.Node) error {
var serverStr *ast.StringNode
if len(*args) > 1 {
// Has the optional server argument
var ok bool
serverStr, ok = (*args)[1].(*ast.StringNode)
if !ok {
return fmt.Errorf("lookup: invalid argument type")
}
}
r := &net.Resolver{
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
if serverStr != nil {
address = serverStr.Value
}
return config.ProtectedDialContext(ctx, network, address)
},
}
if len(*args) > 1 {
(*args)[1] = &ast.ConstantNode{Value: r}
} else {
*args = append(*args, &ast.ConstantNode{Value: r})
}
return nil
},
Func: func(params ...any) (any, error) {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
return params[1].(*net.Resolver).LookupHost(ctx, params[0].(string))
},
Types: []reflect.Type{
reflect.TypeOf((func(string, *net.Resolver) []string)(nil)),
},
},
}
}
+76
View File
@@ -0,0 +1,76 @@
package ruleset
import (
"context"
"net"
"strings"
"testing"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/tcp"
"github.com/apernet/OpenGFW/analyzer/udp"
"github.com/apernet/OpenGFW/modifier"
modUDP "github.com/apernet/OpenGFW/modifier/udp"
)
// builderExpressions are the canonical expressions produced by the visual rule
// builder of the web UI (see web/frontend/src/lib/rule/compile.ts). They are
// pinned here so that a change to the expression language, the analyzers or the
// built-in functions cannot silently break the builder.
var builderExpressions = []string{
// Domain or subdomain, single and multiple values
`(string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com")`,
`((string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com") || (string(tls?.req?.sni) == "evil.test" || string(tls?.req?.sni) endsWith ".evil.test")) && proto == "tcp"`,
// CIDR and GeoIP, including negation
`(cidr(ip.dst, "10.0.0.0/8") || cidr(ip.dst, "fd00::/8")) || !(geoip(ip.dst, "cn") || geoip(ip.dst, "hk"))`,
// Port equality and ranges
`(port.dst >= 1000 && port.dst <= 2000) && (port.src == 80 || port.src == 443)`,
// Wildcards over DNS questions
`any(dns?.questions ?? [], {(string(.name) endsWith ".ads.com" || string(.name) startsWith "x.")})`,
// GeoSite over DNS questions
`any(dns?.questions ?? [], {geosite(string(.name), "category-ads-all")})`,
// HTTP fields: negation, regular expressions and header lookups
`!(string(http?.req?.headers?.host) contains "tracker") && string(http?.req?.path) matches "^/api/v\\d+/" && string(get(http?.req?.headers, "user-agent")) startsWith "curl"`,
// Protocol detection
`(ssh != nil || trojan != nil)`,
// Wildcard edge cases: "any value" and a star in the middle
`string(quic?.req?.sni) != "" && string(tls?.req?.sni) matches "^www\\..*\\.com$" && ip.src == "1.2.3.4" && string(http?.req?.method) == "POST"`,
}
func TestCompileBuilderExpressions(t *testing.T) {
analyzers := []analyzer.Analyzer{
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
}
modifiers := []modifier.Modifier{&modUDP.DNSModifier{}}
config := &BuiltinConfig{
Logger: nopLogger{},
// Point at a file that does not exist: expressions still have to
// compile, only loading the database is expected to fail.
GeoSiteFilename: "testdata/missing-geosite.dat",
GeoIpFilename: "testdata/missing-geoip.dat",
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
}
for _, expr := range builderExpressions {
rules := []ExprRule{{Name: "test", Action: "block", Expr: expr}}
_, err := CompileExprRules(rules, analyzers, modifiers, config)
if err == nil {
continue
}
// geoip()/geosite() need a database, which this test does not ship.
// Reaching the initialization step means the expression itself is fine.
if strings.Contains(err.Error(), "failed to initialize function") {
continue
}
t.Errorf("expression failed to compile: %s\n %v", expr, err)
}
}
type nopLogger struct{}
func (nopLogger) Log(StreamInfo, string) {}
func (nopLogger) MatchError(StreamInfo, string, error) {}
+2
View File
@@ -1,6 +1,7 @@
package ruleset
import (
"context"
"net"
"strconv"
@@ -103,4 +104,5 @@ type BuiltinConfig struct {
Logger Logger
GeoSiteFilename string
GeoIpFilename string
ProtectedDialContext func(ctx context.Context, network, address string) (net.Conn, error)
}
+135
View File
@@ -0,0 +1,135 @@
# OpenGFW Web UI
A Vue 3 + Tailwind CSS dashboard for OpenGFW, built with [shadcn/ui](https://ui.shadcn.com)
style components on top of [Reka UI](https://reka-ui.com) primitives. It is embedded into
the OpenGFW binary and served by the `web` package.
- **Dashboard** — live counters, traffic chart, verdict/protocol split, top hosts,
blocked destinations and triggered rules
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
detail view showing raw analyzer properties
- **Rules** — a visual condition builder, a raw expression editor and a YAML editor,
all validated by the engine itself; saving writes the rule file and hot reloads the
running engine
- **Analyzers** — which analyzers are compiled in and how much traffic each one saw
- **Settings** — theme (light/dark/system), language (English/中文) and instance info
The layout is responsive: a sidebar on desktop, a bottom tab bar and bottom-sheet
dialogs on phones.
## Enabling it
```yaml
# config.yaml
web:
enabled: true
listen: :8080
secret: your-password-here
# cert: /path/to/fullchain.pem
# key: /path/to/privkey.pem
```
If `secret` is empty a random password is generated and printed to the log on startup.
## Rule builder
Rules are still plain expr expressions in the rule file; the builder is only a way to
write them without memorising the syntax. Conditions are rows of *field + operator +
values*, joined with AND or OR, each row negatable:
| Field group | Fields |
| ----------- | --------------------------------------------------------------- |
| Connection | transport protocol, source/destination IP, source/destination port |
| Domain | TLS SNI, QUIC SNI, DNS query name |
| HTTP | Host, path, method, User-Agent |
| Protocol | detected protocol (any analyzer) |
Operators cover the things rules usually need:
| Operator | Generated expression |
| ----------------------- | ---------------------------------------------------------- |
| domain or subdomain of | `(S == "x.com" \|\| S endsWith ".x.com")` |
| matches wildcard | `*.x.com` → `endsWith`, `x.*` → `startsWith`, `*ad*` → `contains`, `a.*.c` → `matches` |
| in CIDR | `cidr(ip.dst, "10.0.0.0/8")`, validated as you type |
| in GeoIP country | `geoip(ip.dst, "cn")`, picked from the loaded database |
| in GeoSite category | `geosite(string(.name), "category-ads-all@cn")` |
| in range | `(port.dst >= 1000 && port.dst <= 2000)` |
| is / contains / starts / ends / regex | the matching expr operator |
Multiple values in one row are OR-ed together, so one row can hold a whole domain or
country list. The generated expression is shown live and validated by the engine before
the rule is accepted.
Opening an existing rule parses its expression back into conditions. Anything the
builder cannot represent — hand written expressions, functions like `lookup()` — opens
in the expression editor with a warning instead of being rewritten.
The GeoIP picker lists whatever the configured `geoip.dat` contains: country codes plus,
with the default Loyalsoldier database, provider groups such as `cloudflare`, `google`
and `telegram`. Matching by AS number is not something the v2geo data format supports,
so use those groups or an explicit CIDR list instead.
`web/frontend/src/lib/rule/` holds the whole thing: `fields.ts` (catalog), `compile.ts`
(builder → expr), `parse.ts` (expr → builder) and `validate.ts`. The canonical
expressions are pinned in `ruleset/expr_test.go`, which compiles them with the real
engine.
## Layout
```
web/
├── api.go HTTP handlers (JSON API + SSE)
├── auth.go password login, session tokens
├── hub.go statistics collection and the live event fan-out
├── server.go routes, static file serving, public types
├── embed.go //go:embed of dist
├── devserver/ standalone server with synthetic data (any OS)
├── dist/ built UI, embedded into the binary (committed)
└── frontend/ Vue sources
```
## Development
The engine only builds on Linux, so for UI work there is a standalone server that
feeds the UI synthetic traffic and an in-memory ruleset:
```bash
go run ./web/devserver # http://127.0.0.1:8080, password: opengfw
```
Then, in another terminal, run Vite with hot reload (it proxies `/api` to `:8080`):
```bash
cd web/frontend
npm install
npm run dev
```
To produce the embedded build (this is what `make web` runs):
```bash
cd web/frontend && npm run build # writes ../dist
```
`web/dist` is committed so that `go build` works without Node installed. Rebuild it
whenever you change the frontend.
## API
All endpoints live under `/api/v1` and return JSON. Except for `login`, every request
must carry `Authorization: Bearer <token>`; `GET` endpoints also accept the session
cookie set at login, which is what the `EventSource` connection uses. Mutating
endpoints only accept the bearer token, which makes them immune to CSRF.
| Method | Path | Description |
| ---------- | -------------------- | -------------------------------------------------- |
| `POST` | `/login` | exchange the password for a session token |
| `POST` | `/logout` | invalidate the current session |
| `GET` | `/info` | version, platform and engine configuration |
| `GET` | `/meta` | available analyzers, modifiers, actions, functions |
| `GET` | `/geo` | GeoIP/GeoSite entries for the rule builder pickers |
| `GET` | `/metrics` | counters, time series and top N lists |
| `GET` | `/events?limit=` | recent events from the ring buffer |
| `GET` | `/live` | server-sent events: `event` and `metrics` frames |
| `GET/PUT` | `/rules` | read / replace the ruleset (`raw` YAML or `rules`) |
| `POST` | `/rules/validate` | compile without applying; also converts YAML ⇄ rules |
+317
View File
@@ -0,0 +1,317 @@
package web
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)
type apiError struct {
Error string `json:"error"`
}
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, apiError{Error: msg})
}
func methodAllowed(w http.ResponseWriter, r *http.Request, methods ...string) bool {
for _, m := range methods {
if r.Method == m {
return true
}
}
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
return false
}
func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB is plenty for a rule file
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
return false
}
return true
}
// POST /api/v1/login
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
var req struct {
Password string `json:"password"`
}
if !decodeBody(w, r, &req) {
return
}
token, expiry, err := s.auth.login(r.RemoteAddr, req.Password)
if err != nil {
code := http.StatusUnauthorized
if errors.Is(err, errTooManyAttempts) {
code = http.StatusTooManyRequests
}
writeError(w, code, err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
Expires: expiry,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]interface{}{
"token": token,
"expiresAt": expiry.UnixMilli(),
})
}
// POST /api/v1/logout
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
s.auth.logout(bearerToken(r))
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.TLS(),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// GET /api/v1/info
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
var info Info
if s.config.Info != nil {
info = s.config.Info()
}
writeJSON(w, http.StatusOK, info)
}
// GET /api/v1/meta
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Meta)
}
// GET /api/v1/metrics
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
writeJSON(w, http.StatusOK, s.config.Hub.Metrics())
}
// GET /api/v1/events?limit=200
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
limit := 200
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
limit = n
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"events": s.config.Hub.Events(limit),
})
}
// GET /api/v1/live - server-sent events carrying live events and metrics.
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
flusher, ok := w.(http.Flusher)
if !ok {
writeError(w, http.StatusInternalServerError, "streaming unsupported")
return
}
h := w.Header()
h.Set("Content-Type", "text/event-stream")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher.Flush()
events, unsubscribe := s.config.Hub.Subscribe()
defer unsubscribe()
metricsTicker := time.NewTicker(2 * time.Second)
defer metricsTicker.Stop()
keepAlive := time.NewTicker(20 * time.Second)
defer keepAlive.Stop()
send := func(event string, v interface{}) bool {
data, err := json.Marshal(v)
if err != nil {
return true
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
return false
}
flusher.Flush()
return true
}
if !send("metrics", s.config.Hub.Metrics()) {
return
}
for {
select {
case <-r.Context().Done():
return
case ev, ok := <-events:
if !ok {
return
}
if !send("event", ev) {
return
}
case <-metricsTicker.C:
if !send("metrics", s.config.Hub.Metrics()) {
return
}
case <-keepAlive.C:
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
return
}
flusher.Flush()
}
}
}
// GET /api/v1/geo - the entries of the configured geo databases.
func (s *Server) handleGeo(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet) {
return
}
if s.config.Geo == nil {
writeJSON(w, http.StatusOK, GeoData{
IPError: "geo databases are unavailable",
SiteError: "geo databases are unavailable",
})
return
}
s.geoMu.Lock()
defer s.geoMu.Unlock()
fresh := s.geoCache != nil && time.Since(s.geoLoaded) < geoCacheTTL
if !fresh || r.URL.Query().Get("reload") == "1" {
data := s.config.Geo()
s.geoCache = &data
s.geoLoaded = time.Now()
}
writeJSON(w, http.StatusOK, s.geoCache)
}
type rulesRequest struct {
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
type rulesResponse struct {
Path string `json:"path"`
Raw string `json:"raw"`
Rules []Rule `json:"rules"`
}
// resolveRaw turns a request into rule file content.
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
if req.Rules != nil {
return s.config.Rules.Marshal(req.Rules)
}
return req.Raw, nil
}
// GET/PUT /api/v1/rules
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
if r.Method == http.MethodGet {
raw, rules, err := s.config.Rules.Load()
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
rules, err := s.config.Rules.Apply(raw)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
}
// POST /api/v1/rules/validate
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
if !methodAllowed(w, r, http.MethodPost) {
return
}
if s.config.Rules == nil {
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
return
}
var req rulesRequest
if !decodeBody(w, r, &req) {
return
}
raw, err := s.resolveRaw(req)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
return
}
rules, err := s.config.Rules.Validate(raw)
if err != nil {
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": false,
"error": err.Error(),
"raw": raw,
})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"valid": true,
"rules": rules,
"raw": raw,
})
}
+152
View File
@@ -0,0 +1,152 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net"
"net/http"
"sync"
"time"
)
const (
sessionCookieName = "opengfw_session"
sessionTTL = 7 * 24 * time.Hour
maxLoginFailures = 8
loginBanDuration = 5 * time.Minute
)
type authenticator struct {
secret string
mu sync.Mutex
sessions map[string]time.Time // token -> expiry
failures map[string]*failureRecord
}
type failureRecord struct {
count int
until time.Time
}
func newAuthenticator(secret string) *authenticator {
return &authenticator{
secret: secret,
sessions: make(map[string]time.Time),
failures: make(map[string]*failureRecord),
}
}
// RandomSecret generates a secret to be used when the user did not set one.
func RandomSecret() string {
b := make([]byte, 12)
if _, err := rand.Read(b); err != nil {
return "opengfw"
}
return hex.EncodeToString(b)
}
func newToken() string {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return ""
}
return hex.EncodeToString(b)
}
// login verifies the password and returns a new session token.
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
ip := hostOnly(remoteAddr)
a.mu.Lock()
defer a.mu.Unlock()
now := time.Now()
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
return "", time.Time{}, errTooManyAttempts
}
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
rec, ok := a.failures[ip]
if !ok || now.After(rec.until) {
rec = &failureRecord{}
a.failures[ip] = rec
}
rec.count++
rec.until = now.Add(loginBanDuration)
return "", time.Time{}, errBadCredentials
}
delete(a.failures, ip)
token := newToken()
if token == "" {
return "", time.Time{}, errInternal
}
expiry := now.Add(sessionTTL)
a.sessions[token] = expiry
a.gcLocked(now)
return token, expiry, nil
}
func (a *authenticator) logout(token string) {
if token == "" {
return
}
a.mu.Lock()
delete(a.sessions, token)
a.mu.Unlock()
}
func (a *authenticator) valid(token string) bool {
if token == "" {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
expiry, ok := a.sessions[token]
if !ok {
return false
}
if time.Now().After(expiry) {
delete(a.sessions, token)
return false
}
return true
}
func (a *authenticator) gcLocked(now time.Time) {
for t, exp := range a.sessions {
if now.After(exp) {
delete(a.sessions, t)
}
}
for ip, rec := range a.failures {
if now.After(rec.until) {
delete(a.failures, ip)
}
}
}
// bearerToken returns the token carried by the Authorization header, if any.
func bearerToken(r *http.Request) string {
const prefix = "Bearer "
h := r.Header.Get("Authorization")
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
return h[len(prefix):]
}
return ""
}
// cookieToken returns the token carried by the session cookie, if any.
func cookieToken(r *http.Request) string {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return ""
}
return c.Value
}
func hostOnly(addr string) string {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return addr
}
return host
}
+287
View File
@@ -0,0 +1,287 @@
// Command devserver runs the OpenGFW web UI against synthetic data.
//
// The engine itself only builds on Linux (it needs NFQueue), so this little
// program exists to let the frontend be developed and reviewed anywhere:
//
// go run ./web/devserver
//
// It serves the embedded UI on :8080 with the password "opengfw".
package main
import (
"context"
"fmt"
"log"
"math/rand"
"net"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/tcp"
"github.com/apernet/OpenGFW/analyzer/udp"
"github.com/apernet/OpenGFW/modifier"
modUDP "github.com/apernet/OpenGFW/modifier/udp"
"github.com/apernet/OpenGFW/ruleset"
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
"github.com/apernet/OpenGFW/web"
"gopkg.in/yaml.v3"
)
const password = "opengfw"
func main() {
hub := web.NewHub()
rm := &memoryRules{}
if err := rm.init(); err != nil {
log.Fatal(err)
}
srv, err := web.NewServer(web.Config{
Listen: ":8080",
Secret: password,
Hub: hub,
Rules: rm,
Meta: web.MetaInfo{
Analyzers: []web.AnalyzerInfo{
{Name: "http", Proto: "tcp"}, {Name: "tls", Proto: "tcp"},
{Name: "ssh", Proto: "tcp"}, {Name: "socks", Proto: "tcp"},
{Name: "trojan", Proto: "tcp"}, {Name: "fet", Proto: "tcp"},
{Name: "dns", Proto: "udp"}, {Name: "quic", Proto: "udp"},
{Name: "openvpn", Proto: "udp"}, {Name: "wireguard", Proto: "udp"},
},
Modifiers: []string{"dns"},
Actions: []string{"allow", "block", "drop", "modify"},
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
},
Info: func() web.Info {
host, _ := os.Hostname()
return web.Info{
Version: "devserver",
Platform: runtime.GOOS + "/" + runtime.GOARCH,
GoVersion: runtime.Version(),
Hostname: host,
RuleFile: "rules.yaml (in memory)",
Config: web.ConfigDigest{
IOQueueSize: 1024, IORST: true, Workers: 4,
WorkerQueue: 64, UDPMaxStreams: 4096,
GeoIP: "geoip.dat", GeoSite: "geosite.dat",
},
}
},
Geo: geoData,
Logf: log.Printf,
})
if err != nil {
log.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
<-ch
cancel()
}()
for i := 0; i < 4; i++ {
hub.WorkerStarted()
}
go generate(ctx, hub)
log.Printf("web UI on http://127.0.0.1:8080 (password: %s)", password)
if err := srv.Run(ctx); err != nil {
log.Fatal(err)
}
}
var (
hosts = []string{
"www.google.com", "github.com", "cdn.jsdelivr.net", "telegram.org",
"ads.example.net", "tracker.evil.test", "api.openai.com", "www.wikipedia.org",
"registry.npmjs.org", "malware.bad.test",
}
ips = []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "104.16.132.229", "2606:4700::6810:84e5"}
)
// generate feeds the hub with plausible looking traffic.
func generate(ctx context.Context, hub *web.Hub) {
rng := rand.New(rand.NewSource(42))
ticker := time.NewTicker(120 * time.Millisecond)
defer ticker.Stop()
var id int64
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
for n := rng.Intn(6); n >= 0; n-- {
id++
udp := rng.Intn(3) == 0
proto := "tcp"
if udp {
proto = "udp"
}
hub.StreamNew(proto)
host := hosts[rng.Intn(len(hosts))]
props := web.Props{}
if udp {
props["dns"] = web.PropMap{
"qr": false,
"questions": []map[string]interface{}{{"name": host, "type": 1}},
}
} else {
props["tls"] = web.PropMap{"req": map[string]interface{}{
"sni": host, "version": 771,
}}
}
hub.PropUpdate(props)
info := web.StreamInfo{
ID: id,
Proto: proto,
SrcIP: fmt.Sprintf("192.168.1.%d", 2+rng.Intn(60)),
SrcPort: uint16(20000 + rng.Intn(40000)),
DstIP: ips[rng.Intn(len(ips))],
DstPort: 443,
Props: props,
}
switch {
case rng.Intn(10) == 0:
hub.RuleLog(info, "log-suspicious")
hub.StreamAction(info, "block")
case rng.Intn(12) == 0:
hub.StreamAction(info, "drop")
case rng.Intn(14) == 0:
hub.StreamAction(info, "modify")
case rng.Intn(30) == 0:
hub.Error(info, "geoip-rule", "lookup timeout")
default:
hub.StreamAction(info, "allow")
}
}
}
}
}
// memoryRules is an in-memory web.RuleManager. Rules are compiled with the real
// ruleset compiler and the real analyzers, so expression errors show up here
// exactly like they would in the engine; only the "apply" step is faked.
type memoryRules struct {
raw string
rules []web.Rule
}
const seedRules = `- name: block-malware
action: block
log: true
expr: 'tls != nil && tls.req != nil && string(tls.req.sni) endsWith ".bad.test"'
- name: block-ads-dns
action: drop
expr: 'dns != nil && any(dns.questions, {.name endsWith "ads.example.net"})'
- name: log-ssh
log: true
expr: 'ssh != nil'
`
func (m *memoryRules) init() error {
rules, err := parse(seedRules)
if err != nil {
return err
}
m.raw, m.rules = seedRules, rules
return nil
}
func (m *memoryRules) Path() string { return "rules.yaml" }
func (m *memoryRules) Load() (string, []web.Rule, error) { return m.raw, m.rules, nil }
func (m *memoryRules) Validate(raw string) ([]web.Rule, error) { return parse(raw) }
func (m *memoryRules) Marshal(rules []web.Rule) (string, error) {
bs, err := yaml.Marshal(rules)
return string(bs), err
}
func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
rules, err := parse(raw)
if err != nil {
return nil, err
}
m.raw, m.rules = raw, rules
return rules, nil
}
var (
analyzers = []analyzer.Analyzer{
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
}
modifiers = []modifier.Modifier{&modUDP.DNSModifier{}}
)
// parse compiles rules the same way the engine does.
func parse(raw string) ([]web.Rule, error) {
exprRules, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
if err != nil {
return nil, fmt.Errorf("failed to parse rules: %w", err)
}
_, err = ruleset.CompileExprRules(exprRules, analyzers, modifiers, &ruleset.BuiltinConfig{
Logger: nopRulesetLogger{},
GeoSiteFilename: os.Getenv("OPENGFW_GEOSITE"),
GeoIpFilename: os.Getenv("OPENGFW_GEOIP"),
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, address)
},
})
if err != nil {
return nil, err
}
out := make([]web.Rule, 0, len(exprRules))
for _, r := range exprRules {
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
if r.Modifier.Name != "" {
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
}
out = append(out, wr)
}
return out, nil
}
type nopRulesetLogger struct{}
func (nopRulesetLogger) Log(ruleset.StreamInfo, string) {}
func (nopRulesetLogger) MatchError(ruleset.StreamInfo, string, error) {}
// geoData lists the geo databases, if they are available in the working
// directory (or wherever OPENGFW_GEOIP / OPENGFW_GEOSITE point).
func geoData() web.GeoData {
matcher := geo.NewGeoMatcher(os.Getenv("OPENGFW_GEOSITE"), os.Getenv("OPENGFW_GEOIP"))
var data web.GeoData
if entries, err := matcher.ListGeoIP(); err != nil {
data.IPError = err.Error()
} else {
for _, e := range entries {
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
}
}
if entries, err := matcher.ListGeoSite(); err != nil {
data.SiteError = err.Error()
} else {
for _, e := range entries {
data.Site = append(data.Site, web.GeoEntry{
Code: e.Code, Count: e.Domains, Attributes: e.Attributes,
})
}
}
return data
}
+11
View File
@@ -0,0 +1,11 @@
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.js";import{c as M,d as B,O as L,$ as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-DIqASLVQ.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-B_11D5UP.js";import{c as C}from"./format-B6sc_rZ4.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const D=M("BoxesIcon",[["path",{d:"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z",key:"lc1i9w"}],["path",{d:"m7 16.5-4.74-2.85",key:"1o9zyk"}],["path",{d:"m7 16.5 5-3",key:"va8pkn"}],["path",{d:"M7 16.5v5.17",key:"jnp8gn"}],["path",{d:"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z",key:"8zsnat"}],["path",{d:"m17 16.5-5-3",key:"8arw3v"}],["path",{d:"m17 16.5 4.74-2.85",key:"8rfmw"}],["path",{d:"M17 16.5v5.17",key:"k6z78m"}],["path",{d:"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z",key:"1xygjf"}],["path",{d:"M12 8 7.26 5.15",key:"1vbdud"}],["path",{d:"m12 8 4.74-2.85",key:"3rx089"}],["path",{d:"M12 13.5V8",key:"1io7kd"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const I=M("SquareFunctionIcon",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["path",{d:"M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3",key:"m1af9g"}],["path",{d:"M9 11.2h5.7",key:"3zgcl2"}]]),S={key:0,class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},Z={class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},E={class:"flex items-center justify-between gap-2"},O={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(K){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",O,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
@@ -0,0 +1 @@
import{d as o,a as n,e as d,u as e,f as s,H as l,o as p,a8 as c}from"./index-DIqASLVQ.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
@@ -0,0 +1 @@
import{d as a,o as n,a as r,e as c,u as o,f as p,H as l}from"./index-DIqASLVQ.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
+1
View File
@@ -0,0 +1 @@
import{aB as h,aC as x,m as v,g as p,aD as R,al as S,d as g,aj as b,ap as w,aE as y,x as P,aF as k}from"./index-DIqASLVQ.js";function D(e,l){const t=typeof e=="string"&&!l?`${e}Context`:l,o=Symbol(t);return[a=>{const c=h(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>(x(o,a),a)]}function _(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[A]=D("ConfigProvider");function z(e){const l=A({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=l.dir)==null?void 0:t.value)||"ltr"})}function K(e,l="reka"){var n;let t;const o=A({useId:void 0});return o.useId?t=o.useId():t=(n=R)==null?void 0:n(),l?`${l}-${t}`:t}function I(){const e=v(),l=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:l}}const $="data-reka-collection-item";function q(e={}){const{key:l="",isProvider:t=!1}=e,o=`${l}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},x(o,n)}else n=h(o);const C=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${$}]`)),m=new Map(f.map((i,E)=>[i,E])),d=Array.from(n.itemMap.value.values()).sort((i,E)=>(m.get(i.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(i=>i.ref.dataset.disabled!=="")},a=g({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return P(s,()=>{n.collectionRef.value=s.value}),()=>w(y,{ref:m,...f},r)}}),c=g({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return b(d=>{if(s.value){const i=k(s.value);n.itemMap.value.set(i,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(i))}}),()=>w(y,{...f,[$]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),j=p(()=>n.itemMap.value.size);return{getItems:C,reactiveItems:M,itemMapSize:j,CollectionSlot:a,CollectionItem:c}}export{q as a,z as b,D as c,K as d,_ as g,A as i,I as u};
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-DIqASLVQ.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const w=h("ArrowRightIcon",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]),y=x({__name:"ActionBadge",props:{event:{}},setup(l){const e=l,u=v(()=>{if(e.event.kind==="error")return"block";if(e.event.kind==="log")return"log";switch(e.event.action){case"allow":return"allow";case"block":return"block";case"drop":return"drop";case"modify":return"modify";default:return"muted"}}),s=v(()=>e.event.kind==="error"?d("action.error"):e.event.kind==="log"?d("action.log"):d(`action.${e.event.action??"maybe"}`));return(g,N)=>(o(),m(t(a),{variant:u.value,class:"uppercase"},{default:c(()=>[i(n(s.value),1)]),_:1},8,["variant"]))}}),B={class:"flex items-center gap-2"},I={class:"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums"},$={class:"flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs"},A={class:"text-muted-foreground truncate"},C={class:"truncate"},E=x({__name:"EventItem",props:{event:{}},emits:["select"],setup(l){const e=l;return(u,s)=>(o(),k("button",{type:"button",class:"hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3",onClick:s[0]||(s[0]=g=>u.$emit("select",e.event))},[r("div",B,[r("span",I,n(t(b)(e.event.time)),1),p(y,{event:e.event},null,8,["event"]),p(t(a),{variant:e.event.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:c(()=>[i(n(e.event.proto),1)]),_:1},8,["variant"]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"max-w-32 truncate sm:hidden"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]),r("div",$,[r("span",A,n(t(_)(e.event.srcIP,e.event.srcPort)),1),p(t(w),{class:"text-muted-foreground/60 size-3 shrink-0"}),r("span",C,n(e.event.host||t(_)(e.event.dstIP,e.event.dstPort)),1)]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"hidden max-w-40 truncate sm:inline-flex"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]))}});export{w as A,E as _,y as a};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
import{d as c,a as t,b as n,t as i,j as a,H as d,o as s}from"./index-DIqASLVQ.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,O as F,$ as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a9 as E,a0 as q,g as v,aa as H,v as U,ab as A,ac as G}from"./index-DIqASLVQ.js";import{_ as J}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as x,a as h,c as b,b as K}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-CzaVl8rJ.js";import"./Collection-CwtwoTtI.js";/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const W=y("BookOpenIcon",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const X=y("LogOutIcon",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/**
* @license lucide-vue-next v0.469.0 - ISC
*
* This source code is licensed under the ISC license.
* See the LICENSE file in the root directory of this source tree.
*/const Y=y("MonitorIcon",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]),Z=M({__name:"Separator",props:{class:{},orientation:{default:"horizontal"}},setup(k){const g=k;return(w,S)=>(u(),r("div",{role:"separator",class:L(t(T)("bg-border shrink-0",g.orientation==="vertical"?"h-full w-px":"h-px w-full",g.class))},null,2))}}),ee={class:"max-w-3xl"},te={class:"flex flex-col gap-2"},se={class:"text-sm font-medium"},ae={class:"bg-muted grid grid-cols-3 gap-1 rounded-lg p-1"},le=["onClick"],ne={class:"flex items-center justify-between gap-4"},oe={class:"text-sm font-medium"},ie={class:"flex flex-col"},ue={class:"text-muted-foreground shrink-0"},re={class:"truncate font-mono text-xs sm:text-[13px]"},ce={class:"flex flex-col"},de={class:"text-muted-foreground shrink-0"},ge={class:"truncate font-mono text-xs sm:text-[13px]"},he=M({__name:"SettingsView",setup(k){const g=U(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:A},{value:"dark",label:e("settings.themeDark"),icon:G},{value:"system",label:e("settings.themeSystem"),icon:Y}]),O=[{value:"en",label:"English"},{value:"zh",label:"中文"}],Q=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),j=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function I(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(J,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:O,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(K),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(Q.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:I},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
@@ -0,0 +1 @@
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-DIqASLVQ.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
function o(t){if(!Number.isFinite(t))return"0";if(t<1e3)return String(t);const e=["k","M","G","T"];let n=t,r=-1;for(;n>=1e3&&r<e.length-1;)n/=1e3,r++;return`${n>=100?Math.round(n):n.toFixed(1).replace(/\.0$/,"")}${e[r]}`}function i(t,e){return e?Math.round(t/e*100):0}function u(t){if(t<60)return`${Math.max(0,Math.floor(t))}s`;const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`}function a(t){return new Date(t).toLocaleTimeString(void 0,{hour12:!1})}function f(t){const e=new Date(t);return`${e.toLocaleDateString()} ${e.toLocaleTimeString(void 0,{hour12:!1})}`}function c(t,e){return t.includes(":")?`[${t}]:${e}`:`${t}:${e}`}export{f as a,o as c,u as d,c as e,i as p,a as t};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
fill="none"
stroke="#a1a1aa"
stroke-width="2"
stroke-linejoin="round"
/>
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+33
View File
@@ -0,0 +1,33 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
/>
<meta name="theme-color" content="#09090b" />
<meta name="color-scheme" content="light dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>OpenGFW</title>
<script>
// Apply the stored theme before first paint to avoid a flash.
;(function () {
try {
var t = localStorage.getItem('opengfw.theme') || 'system'
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
})()
</script>
<script type="module" crossorigin src="/assets/index-DIqASLVQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DDyrZ70V.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
package web
import "embed"
// distFS holds the compiled web UI. Run `make web` (or `npm run build` inside
// web/frontend) to refresh it; the checked-in placeholder simply tells the user
// that the UI has not been built yet.
//
//go:embed all:dist
var distFS embed.FS
+32
View File
@@ -0,0 +1,32 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
/>
<meta name="theme-color" content="#09090b" />
<meta name="color-scheme" content="light dark" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<title>OpenGFW</title>
<script>
// Apply the stored theme before first paint to avoid a flash.
;(function () {
try {
var t = localStorage.getItem('opengfw.theme') || 'system'
var dark =
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
document.documentElement.classList.toggle('dark', dark)
} catch (e) {}
})()
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2501
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "opengfw-web",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "Web UI for OpenGFW",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc -b --noEmit"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-vue-next": "^0.469.0",
"reka-ui": "^2.5.0",
"tailwind-merge": "^3.3.1",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.10.5",
"@vitejs/plugin-vue": "^5.2.1",
"tailwindcss": "^4.1.11",
"typescript": "~5.7.3",
"vite": "^6.0.7",
"vue-tsc": "^2.2.0"
}
}
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#18181b" />
<path
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
fill="none"
stroke="#a1a1aa"
stroke-width="2"
stroke-linejoin="round"
/>
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 388 B

+10
View File
@@ -0,0 +1,10 @@
<script setup lang="ts">
import { RouterView } from 'vue-router'
import Toaster from '@/components/Toaster.vue'
</script>
<template>
<RouterView />
<Toaster />
</template>
+208
View File
@@ -0,0 +1,208 @@
@import 'tailwindcss';
@custom-variant dark (&:is(.dark *));
:root {
--radius: 0.65rem;
--background: oklch(1 0 0);
--foreground: oklch(0.141 0.005 285.823);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.005 285.823);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.005 285.823);
--primary: oklch(0.21 0.006 285.885);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.967 0.001 286.375);
--secondary-foreground: oklch(0.21 0.006 285.885);
--muted: oklch(0.967 0.001 286.375);
--muted-foreground: oklch(0.552 0.016 285.938);
--accent: oklch(0.967 0.001 286.375);
--accent-foreground: oklch(0.21 0.006 285.885);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(0.92 0.004 286.32);
--input: oklch(0.92 0.004 286.32);
--ring: oklch(0.705 0.015 286.067);
/* Verdict colors, shared by badges, charts and the live feed. */
--allow: oklch(0.63 0.14 155);
--block: oklch(0.58 0.22 27);
--drop: oklch(0.72 0.16 70);
--modify: oklch(0.6 0.16 255);
--log: oklch(0.62 0.11 215);
--tcp: oklch(0.62 0.15 265);
--udp: oklch(0.68 0.13 190);
}
.dark {
--background: oklch(0.141 0.005 285.823);
--foreground: oklch(0.985 0 0);
--card: oklch(0.19 0.006 285.885);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.21 0.006 285.885);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.92 0.004 286.32);
--primary-foreground: oklch(0.21 0.006 285.885);
--secondary: oklch(0.274 0.006 286.033);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.274 0.006 286.033);
--muted-foreground: oklch(0.705 0.015 286.067);
--accent: oklch(0.274 0.006 286.033);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.552 0.016 285.938);
--allow: oklch(0.72 0.16 155);
--block: oklch(0.68 0.2 22);
--drop: oklch(0.79 0.15 75);
--modify: oklch(0.7 0.15 255);
--log: oklch(0.72 0.11 215);
--tcp: oklch(0.7 0.15 265);
--udp: oklch(0.76 0.12 190);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-allow: var(--allow);
--color-block: var(--block);
--color-drop: var(--drop);
--color-modify: var(--modify);
--color-log: var(--log);
--color-tcp: var(--tcp);
--color-udp: var(--udp);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--animate-fade-in: fade-in 0.18s ease-out;
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-up {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes slide-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@keyframes pulse-dot {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
@layer base {
* {
border-color: var(--color-border);
}
html {
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
body {
background-color: var(--color-background);
color: var(--color-foreground);
font-family:
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
overscroll-behavior-y: none;
}
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 999px;
}
::-webkit-scrollbar-thumb:hover {
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
background-clip: content-box;
}
}
@utility safe-bottom {
padding-bottom: max(env(safe-area-inset-bottom), 0px);
}
@utility safe-top {
padding-top: max(env(safe-area-inset-top), 0px);
}
@@ -0,0 +1,36 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Badge } from '@/components/ui'
import { t } from '@/i18n'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
const variant = computed(() => {
if (props.event.kind === 'error') return 'block'
if (props.event.kind === 'log') return 'log'
switch (props.event.action) {
case 'allow':
return 'allow'
case 'block':
return 'block'
case 'drop':
return 'drop'
case 'modify':
return 'modify'
default:
return 'muted'
}
})
const label = computed(() => {
if (props.event.kind === 'error') return t('action.error')
if (props.event.kind === 'log') return t('action.log')
return t(`action.${props.event.action ?? 'maybe'}`)
})
</script>
<template>
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
</template>
+117
View File
@@ -0,0 +1,117 @@
<script setup lang="ts">
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
import { computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute } from 'vue-router'
import StatusPill from '@/components/StatusPill.vue'
import { startLive, stopLive } from '@/composables/useLive'
import { useTheme } from '@/composables/useTheme'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const route = useRoute()
useTheme()
const items = computed(() => [
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
{ to: '/events', label: t('nav.events'), icon: Radio },
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
])
const title = computed(() => {
const item = items.value.find((i) => i.to === route.path)
return item?.label ?? t('app.name')
})
onMounted(startLive)
onUnmounted(stopLive)
</script>
<template>
<div class="bg-background min-h-dvh">
<!-- Desktop sidebar -->
<aside
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
>
<div class="flex items-center gap-2 px-2 pb-4">
<div
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
>
G
</div>
<div class="min-w-0">
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
</div>
</div>
<nav class="flex flex-1 flex-col gap-0.5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
route.path === item.to
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)
"
>
<component :is="item.icon" class="size-4 shrink-0" />
{{ item.label }}
</RouterLink>
</nav>
<div class="px-2 pt-3">
<StatusPill />
</div>
</aside>
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
<!-- Mobile top bar -->
<header
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
>
<div class="flex h-12 items-center justify-between gap-3 px-4">
<span class="truncate text-sm font-semibold">{{ title }}</span>
<StatusPill />
</div>
</header>
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
<RouterView v-slot="{ Component }">
<component :is="Component" class="animate-fade-in" />
</RouterView>
</main>
</div>
<!-- Mobile bottom navigation -->
<nav
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
>
<div class="grid grid-cols-5">
<RouterLink
v-for="item in items"
:key="item.to"
:to="item.to"
:class="
cn(
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
)
"
>
<component
:is="item.icon"
:class="cn('size-5', route.path === item.to && 'text-primary')"
/>
<span class="truncate">{{ item.label }}</span>
</RouterLink>
</div>
</nav>
</div>
</template>
+42
View File
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed } from 'vue'
import { compact } from '@/lib/format'
import type { NameCount } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
{ tone: 'primary' },
)
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
const bars: Record<string, string> = {
primary: 'bg-primary/15',
block: 'bg-block/15',
allow: 'bg-allow/15',
}
</script>
<template>
<div v-if="props.items.length" class="flex flex-col gap-1">
<div
v-for="item in props.items"
:key="item.name"
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
>
<div
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
/>
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
compact(item.count)
}}</span>
</div>
</div>
<p v-else class="text-muted-foreground py-6 text-center text-xs">
{{ props.empty ?? '—' }}
</p>
</template>
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
import { ArrowRight } from 'lucide-vue-next'
import ActionBadge from '@/components/ActionBadge.vue'
import { Badge } from '@/components/ui'
import { endpoint, time } from '@/lib/format'
import type { LiveEvent } from '@/lib/types'
const props = defineProps<{ event: LiveEvent }>()
defineEmits<{ select: [event: LiveEvent] }>()
</script>
<template>
<button
type="button"
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
@click="$emit('select', props.event)"
>
<div class="flex items-center gap-2">
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
{{ time(props.event.time) }}
</span>
<ActionBadge :event="props.event" />
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
{{ props.event.proto }}
</Badge>
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
{{ props.event.rule }}
</Badge>
</div>
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
<span class="text-muted-foreground truncate">
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
</span>
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
<span class="truncate">
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
</span>
</div>
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
{{ props.event.rule }}
</Badge>
</button>
</template>
@@ -0,0 +1,17 @@
<script setup lang="ts">
const props = defineProps<{ title: string; description?: string }>()
</script>
<template>
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
<div class="min-w-0">
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
{{ props.description }}
</p>
</div>
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
<slot name="actions" />
</div>
</div>
</template>
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Component } from 'vue'
import { Card } from '@/components/ui'
import { cn } from '@/lib/utils'
const props = defineProps<{
label: string
value: string
hint?: string
icon?: Component
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
}>()
const tones: Record<string, string> = {
default: 'text-muted-foreground',
allow: 'text-allow',
block: 'text-block',
drop: 'text-drop',
modify: 'text-modify',
}
</script>
<template>
<Card class="p-3.5 sm:p-4">
<div class="flex items-start justify-between gap-2">
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
<component
:is="props.icon"
v-if="props.icon"
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
/>
</div>
<div class="mt-1.5 flex items-baseline gap-1.5">
<span
:class="
cn(
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
)
"
>{{ props.value }}</span
>
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
props.hint
}}</span>
</div>
</Card>
</template>
@@ -0,0 +1,49 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useLive } from '@/composables/useLive'
import { t } from '@/i18n'
import { cn } from '@/lib/utils'
const { status } = useLive()
const label = computed(() => {
switch (status.value) {
case 'open':
return t('common.live')
case 'connecting':
return t('common.connecting')
default:
return t('common.offline')
}
})
</script>
<template>
<span
:class="
cn(
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
status === 'open'
? 'border-allow/30 bg-allow/10 text-allow'
: status === 'connecting'
? 'border-drop/30 bg-drop/10 text-drop'
: 'border-block/30 bg-block/10 text-block',
)
"
>
<span
:class="
cn(
'size-1.5 rounded-full',
status === 'open'
? 'bg-allow animate-pulse-dot'
: status === 'connecting'
? 'bg-drop animate-pulse-dot'
: 'bg-block',
)
"
/>
{{ label }}
</span>
</template>
+52
View File
@@ -0,0 +1,52 @@
<script setup lang="ts">
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
import { dismiss, useToast } from '@/composables/useToast'
import { cn } from '@/lib/utils'
const { toasts } = useToast()
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
>
<TransitionGroup
enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
leave-active-class="transition duration-150 ease-in absolute"
leave-to-class="opacity-0 scale-95"
>
<div
v-for="item in toasts"
:key="item.id"
:class="
cn(
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
item.variant === 'error' && 'border-block/40',
item.variant === 'success' && 'border-allow/40',
)
"
>
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
<TriangleAlert
v-else-if="item.variant === 'error'"
class="text-block mt-0.5 size-4 shrink-0"
/>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium">{{ item.title }}</p>
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
{{ item.description }}
</p>
</div>
<button
type="button"
class="text-muted-foreground hover:text-foreground -m-1 p-1"
@click="dismiss(item.id)"
>
<X class="size-3.5" />
</button>
</div>
</TransitionGroup>
</div>
</template>
@@ -0,0 +1,168 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { t } from '@/i18n'
import { compact, time } from '@/lib/format'
import type { Bucket } from '@/lib/types'
const props = defineProps<{ buckets: Bucket[] }>()
const W = 600
const H = 160
const PAD_TOP = 8
const PAD_BOTTOM = 16
const hover = ref<number | null>(null)
const points = computed(() => props.buckets ?? [])
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
function x(i: number): number {
const n = points.value.length
return n <= 1 ? 0 : (i / (n - 1)) * W
}
function y(value: number): number {
const usable = H - PAD_TOP - PAD_BOTTOM
return PAD_TOP + usable - (value / max.value) * usable
}
/** Builds a closed area path for a series of stacked values. */
function area(values: number[]): string {
if (values.length === 0) return ''
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
const base = `L${W},${y(0)} L0,${y(0)} Z`
return top.join(' ') + ' ' + base
}
function line(values: number[]): string {
return values
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
.join(' ')
}
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
function onMove(event: PointerEvent) {
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
const ratio = (event.clientX - rect.left) / rect.width
const index = Math.round(ratio * (points.value.length - 1))
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
}
const legend = computed(() => {
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
points.value.reduce((acc, b) => acc + b[key], 0)
return [
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
]
})
</script>
<template>
<div>
<div
class="relative touch-pan-y"
@pointermove="onMove"
@pointerdown="onMove"
@pointerleave="hover = null"
>
<svg
:viewBox="`0 0 ${W} ${H}`"
preserveAspectRatio="none"
class="h-36 w-full sm:h-44"
role="img"
>
<line
v-for="frac in [0, 0.5, 1]"
:key="frac"
:x1="0"
:x2="W"
:y1="y(max * frac)"
:y2="y(max * frac)"
class="stroke-border"
stroke-width="1"
vector-effect="non-scaling-stroke"
stroke-dasharray="3 4"
/>
<path :d="totalPath" class="fill-udp/25" />
<path :d="tcpPath" class="fill-tcp/45" />
<path
:d="blockedPath"
class="stroke-block"
fill="none"
stroke-width="1.75"
stroke-linejoin="round"
vector-effect="non-scaling-stroke"
/>
<template v-if="hover !== null">
<line
:x1="x(hover)"
:x2="x(hover)"
:y1="PAD_TOP"
:y2="y(0)"
class="stroke-foreground/40"
stroke-width="1"
vector-effect="non-scaling-stroke"
/>
</template>
</svg>
<div
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
>
{{ compact(max) }}
</div>
<div
v-if="active"
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
:style="{
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
transform:
hover! / Math.max(1, points.length - 1) > 0.6
? 'translateX(-105%)'
: 'translateX(8px)',
}"
>
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
<span class="ml-auto font-medium">{{ active.tcp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
<span class="ml-auto font-medium">{{ active.udp }}</span>
</div>
<div class="flex items-center gap-2 tabular-nums">
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
<span class="ml-auto font-medium">{{ active.blocked }}</span>
</div>
</div>
<div
v-if="!hasData"
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
>
{{ t('dashboard.noTraffic') }}
</div>
</div>
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
<span :class="['size-2 rounded-full', item.color]" />
<span class="text-muted-foreground">{{ item.label }}</span>
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,236 @@
<script setup lang="ts">
import { Trash2 } from 'lucide-vue-next'
import { computed } from 'vue'
import GeoPicker from '@/components/rule/GeoPicker.vue'
import { Button, Input, Select, TagsInput } from '@/components/ui'
import { t } from '@/i18n'
import { explainWildcard } from '@/lib/rule/compile'
import {
FIELDS,
FIELD_MAP,
PROTO_VALUES,
defaultOperator,
operatorsFor,
type OperatorId,
} from '@/lib/rule/fields'
import type { Condition } from '@/lib/rule/types'
import { validateValue } from '@/lib/rule/validate'
import { cn } from '@/lib/utils'
const props = defineProps<{
condition: Condition
/** Analyzer names offered by the "protocol detected" field. */
analyzers: string[]
removable: boolean
}>()
const emit = defineEmits<{
update: [condition: Condition]
remove: []
}>()
const field = computed(() => FIELD_MAP[props.condition.field] ?? FIELDS[0])
/** Field keys contain dots, which the translation lookup uses for nesting. */
const labelKey = (key: string) => `rules.field.${key.replace(/\./g, '_')}`
const fieldOptions = computed(() =>
FIELDS.map((f) => ({
value: f.key,
label: `${t(`rules.group.${f.group}`)} · ${t(labelKey(f.key))}`,
})),
)
const operatorOptions = computed(() =>
operatorsFor(field.value).map((op) => ({ value: op, label: t(`rules.op.${op}`) })),
)
function patch(changes: Partial<Condition>) {
emit('update', { ...props.condition, ...changes })
}
function changeField(key: string) {
const next = FIELD_MAP[key]
if (!next) return
const keepOperator = operatorsFor(next).includes(props.condition.op)
patch({
field: key,
op: keepOperator ? props.condition.op : defaultOperator(next.type),
values: keepOperator && next.type === field.value.type ? props.condition.values : [],
})
}
function changeOperator(op: string) {
patch({ op: op as OperatorId, values: [] })
}
function toggleValue(value: string) {
const values = props.condition.values.includes(value)
? props.condition.values.filter((v) => v !== value)
: [...props.condition.values, value]
patch({ values })
}
const validator = (value: string) => validateValue(field.value.type, props.condition.op, value)
const placeholder = computed(() => {
switch (props.condition.op) {
case 'cidr':
return '10.0.0.0/8, 2001:db8::/32'
case 'wildcard':
return '*.example.com'
case 'regex':
return '^/api/v\\d+/'
default:
break
}
switch (field.value.type) {
case 'ip':
return '1.2.3.4'
case 'port':
return '443'
case 'domain':
return 'example.com'
default:
return t('rules.valuePlaceholder')
}
})
/** Human readable explanation of each wildcard pattern. */
const wildcardHints = computed(() => {
if (props.condition.op !== 'wildcard') return []
return props.condition.values
.filter(Boolean)
.slice(0, 4)
.map((value) => {
const info = explainWildcard(value)
return `${value} → ${t(`rules.wildcard.${info.kind}`, { value: info.value })}`
})
})
</script>
<template>
<div class="bg-muted/40 flex flex-col gap-2 rounded-lg border p-2.5">
<div class="flex flex-wrap items-center gap-2">
<Select
:model-value="props.condition.field"
:options="fieldOptions"
:aria-label="t('rules.builder.field')"
class="h-8 min-w-0 flex-1 basis-45 text-xs sm:basis-56"
@update:model-value="changeField"
/>
<Select
:model-value="props.condition.op"
:options="operatorOptions"
:aria-label="t('rules.builder.operator')"
class="h-8 min-w-0 flex-1 basis-32 text-xs sm:basis-40 sm:flex-none"
@update:model-value="changeOperator"
/>
<button
type="button"
:class="
cn(
'h-8 shrink-0 rounded-md border px-2 text-xs font-medium transition-colors',
condition.negate
? 'border-block/40 bg-block/10 text-block'
: 'text-muted-foreground hover:bg-accent',
)
"
:aria-pressed="props.condition.negate"
@click="patch({ negate: !props.condition.negate })"
>
{{ t('rules.builder.not') }}
</button>
<Button
v-if="props.removable"
variant="ghost"
size="icon-sm"
class="text-muted-foreground hover:text-destructive shrink-0"
:aria-label="t('common.delete')"
@click="emit('remove')"
>
<Trash2 class="size-3.5" />
</Button>
</div>
<!-- Protocol: fixed choices -->
<div v-if="field.type === 'proto'" class="flex flex-wrap gap-1.5">
<button
v-for="value in PROTO_VALUES"
:key="value"
type="button"
:class="
cn(
'rounded-md border px-2.5 py-1 font-mono text-xs uppercase transition-colors',
props.condition.values.includes(value)
? 'border-primary/40 bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-accent',
)
"
@click="toggleValue(value)"
>
{{ value }}
</button>
</div>
<!-- Protocol detection: analyzer chips -->
<div v-else-if="field.type === 'analyzer'" class="flex flex-wrap gap-1.5">
<button
v-for="name in props.analyzers"
:key="name"
type="button"
:class="
cn(
'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors',
props.condition.values.includes(name)
? 'border-primary/40 bg-primary/10 text-foreground'
: 'text-muted-foreground hover:bg-accent',
)
"
@click="toggleValue(name)"
>
{{ name }}
</button>
</div>
<!-- Port range -->
<div v-else-if="condition.op === 'range'" class="flex items-center gap-2">
<Input
:model-value="props.condition.values[0] ?? ''"
inputmode="numeric"
placeholder="1024"
class="h-8 w-28 text-xs"
@update:model-value="patch({ values: [$event, props.condition.values[1] ?? ''] })"
/>
<span class="text-muted-foreground text-xs">–</span>
<Input
:model-value="props.condition.values[1] ?? ''"
inputmode="numeric"
placeholder="65535"
class="h-8 w-28 text-xs"
@update:model-value="patch({ values: [props.condition.values[0] ?? '', $event] })"
/>
</div>
<!-- GeoIP / GeoSite pickers -->
<GeoPicker
v-else-if="props.condition.op === 'geoip' || props.condition.op === 'geosite'"
:model-value="props.condition.values"
:kind="props.condition.op"
@update:model-value="patch({ values: $event })"
/>
<!-- Everything else: a list of values -->
<template v-else>
<TagsInput
:model-value="props.condition.values"
:placeholder="placeholder"
:validate="validator"
@update:model-value="patch({ values: $event })"
/>
<p v-for="hint in wildcardHints" :key="hint" class="text-muted-foreground font-mono text-[11px]">
{{ hint }}
</p>
</template>
</div>
</template>
@@ -0,0 +1,199 @@
<script setup lang="ts">
import { Check, ChevronsUpDown, Search, X } from 'lucide-vue-next'
import {
ComboboxAnchor,
ComboboxContent,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxPortal,
ComboboxRoot,
ComboboxTrigger,
ComboboxViewport,
} from 'reka-ui'
import { computed, ref } from 'vue'
import { TagsInput } from '@/components/ui'
import { useGeo } from '@/composables/useGeo'
import { t, useI18n } from '@/i18n'
import { flagOf, regionName } from '@/lib/geo'
import { cn } from '@/lib/utils'
/**
* Multi-select for geoip() country codes and geosite() categories, backed by
* the databases the engine actually loaded. Falls back to a plain chip input
* when the databases are not available.
*/
const props = defineProps<{ modelValue: string[]; kind: 'geoip' | 'geosite' }>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const { lang } = useI18n()
const { geo, error, loading, load } = useGeo()
const search = ref('')
interface Option {
value: string
label: string
hint: string
flag: string
}
const options = computed<Option[]>(() => {
const entries = (props.kind === 'geoip' ? geo.value?.ip : geo.value?.site) ?? []
const out: Option[] = []
for (const entry of entries) {
if (props.kind === 'geoip') {
const name = regionName(entry.code, lang.value)
out.push({
value: entry.code,
label: name ?? entry.code,
hint: `${entry.code.toUpperCase()} · ${entry.count}`,
flag: flagOf(entry.code),
})
} else {
out.push({
value: entry.code,
label: entry.code,
hint: String(entry.count),
flag: '',
})
for (const attr of entry.attributes ?? []) {
out.push({
value: `${entry.code}@${attr}`,
label: `${entry.code}@${attr}`,
hint: t('rules.geo.attribute'),
flag: '',
})
}
}
}
return out
})
const filtered = computed(() => {
const q = search.value.trim().toLowerCase()
const list = q
? options.value.filter((o) => o.value.includes(q) || o.label.toLowerCase().includes(q))
: options.value
return list.slice(0, 300)
})
const unavailable = computed(
() => !loading.value && options.value.length === 0,
)
const errorMessage = computed(() =>
props.kind === 'geoip' ? error.value.ip : error.value.site,
)
function remove(value: string) {
emit(
'update:modelValue',
props.modelValue.filter((v) => v !== value),
)
}
function labelOf(value: string): string {
const option = options.value.find((o) => o.value === value)
if (option) return `${option.flag} ${option.label}`.trim()
return value
}
void load()
</script>
<template>
<div class="flex flex-col gap-1.5">
<template v-if="unavailable">
<TagsInput
:model-value="props.modelValue"
:placeholder="props.kind === 'geoip' ? 'cn, us, private' : 'category-ads-all'"
@update:model-value="emit('update:modelValue', $event)"
/>
<p class="text-muted-foreground text-xs">
{{ t('rules.geo.unavailable') }}
<span v-if="errorMessage" class="font-mono">({{ errorMessage }})</span>
</p>
</template>
<template v-else>
<div v-if="props.modelValue.length" class="flex flex-wrap gap-1.5">
<span
v-for="value in props.modelValue"
:key="value"
class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs"
>
{{ labelOf(value) }}
<button
type="button"
class="opacity-60 transition-opacity hover:opacity-100"
:aria-label="t('common.delete')"
@click="remove(value)"
>
<X class="size-3" />
</button>
</span>
</div>
<p v-if="loading" class="text-muted-foreground text-xs">{{ t('rules.geo.loading') }}</p>
<ComboboxRoot
:model-value="props.modelValue"
multiple
ignore-filter
:reset-search-term-on-blur="false"
@update:model-value="emit('update:modelValue', ($event as unknown as string[]) ?? [])"
>
<ComboboxAnchor
class="border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-9 w-full items-center gap-2 rounded-md border px-3 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]"
>
<Search class="text-muted-foreground size-4 shrink-0" />
<ComboboxInput
v-model="search"
:placeholder="
props.kind === 'geoip' ? t('rules.geo.searchCountry') : t('rules.geo.searchSite')
"
class="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
/>
<ComboboxTrigger class="text-muted-foreground shrink-0">
<ChevronsUpDown class="size-4" />
</ComboboxTrigger>
</ComboboxAnchor>
<ComboboxPortal>
<ComboboxContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in z-50 max-h-72 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<ComboboxViewport class="max-h-72 overflow-y-auto p-1">
<ComboboxEmpty class="text-muted-foreground px-2 py-4 text-center text-xs">
{{ t('common.empty') }}
</ComboboxEmpty>
<ComboboxItem
v-for="option in filtered"
:key="option.value"
:value="option.value"
:class="
cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none',
)
"
>
<span v-if="option.flag" class="shrink-0">{{ option.flag }}</span>
<span class="truncate">{{ option.label }}</span>
<span class="text-muted-foreground ml-auto shrink-0 text-[11px] tabular-nums">
{{ option.hint }}
</span>
<Check
v-if="props.modelValue.includes(option.value)"
class="absolute right-2 size-4"
/>
</ComboboxItem>
</ComboboxViewport>
</ComboboxContent>
</ComboboxPortal>
</ComboboxRoot>
</template>
</div>
</template>
@@ -0,0 +1,96 @@
<script setup lang="ts">
import { Plus } from 'lucide-vue-next'
import { computed } from 'vue'
import ConditionRow from '@/components/rule/ConditionRow.vue'
import { Button } from '@/components/ui'
import { t } from '@/i18n'
import { compileRule } from '@/lib/rule/compile'
import { newCondition, type Condition, type RuleBuilderState } from '@/lib/rule/types'
import { cn } from '@/lib/utils'
const props = defineProps<{ modelValue: RuleBuilderState; analyzers: string[] }>()
const emit = defineEmits<{ 'update:modelValue': [value: RuleBuilderState] }>()
const expression = computed(() => compileRule(props.modelValue))
function patch(changes: Partial<RuleBuilderState>) {
emit('update:modelValue', { ...props.modelValue, ...changes })
}
function updateCondition(index: number, condition: Condition) {
const conditions = [...props.modelValue.conditions]
conditions[index] = condition
patch({ conditions })
}
function removeCondition(index: number) {
patch({ conditions: props.modelValue.conditions.filter((_, i) => i !== index) })
}
function addCondition() {
patch({ conditions: [...props.modelValue.conditions, newCondition()] })
}
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">{{ t('rules.builder.match') }}</span>
<div class="bg-muted inline-flex rounded-md p-0.5">
<button
v-for="mode in ['and', 'or'] as const"
:key="mode"
type="button"
:class="
cn(
'rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors',
props.modelValue.mode === mode
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)
"
@click="patch({ mode })"
>
{{ t(`rules.builder.${mode}`) }}
</button>
</div>
<span class="text-muted-foreground">{{ t('rules.builder.matchSuffix') }}</span>
</div>
<div class="flex flex-col gap-2">
<template v-for="(condition, index) in props.modelValue.conditions" :key="condition.id">
<div v-if="index > 0" class="flex items-center gap-2">
<span class="bg-border h-px flex-1" />
<span class="text-muted-foreground text-[11px] font-medium uppercase">
{{ t(`rules.builder.${props.modelValue.mode}`) }}
</span>
<span class="bg-border h-px flex-1" />
</div>
<ConditionRow
:condition="condition"
:analyzers="props.analyzers"
:removable="props.modelValue.conditions.length > 1"
@update="updateCondition(index, $event)"
@remove="removeCondition(index)"
/>
</template>
</div>
<Button variant="outline" size="sm" class="w-full" @click="addCondition">
<Plus class="size-3.5" />
{{ t('rules.builder.addCondition') }}
</Button>
<div class="flex flex-col gap-1">
<span class="text-muted-foreground text-xs font-medium">
{{ t('rules.builder.preview') }}
</span>
<code
class="bg-muted/60 text-muted-foreground block rounded-md px-2.5 py-2 font-mono text-[11px] leading-relaxed break-all"
>
{{ expression || t('rules.builder.empty') }}
</code>
</div>
</div>
</template>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const badgeVariants = cva(
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
outline: 'text-foreground',
muted: 'border-transparent bg-muted text-muted-foreground',
allow: 'border-allow/25 bg-allow/12 text-allow',
block: 'border-block/25 bg-block/12 text-block',
drop: 'border-drop/25 bg-drop/12 text-drop',
modify: 'border-modify/25 bg-modify/12 text-modify',
log: 'border-log/25 bg-log/12 text-log',
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
udp: 'border-udp/25 bg-udp/12 text-udp',
},
},
defaultVariants: { variant: 'default' },
},
)
export type BadgeVariants = VariantProps<typeof badgeVariants>
</script>
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
</script>
<template>
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
<slot />
</span>
</template>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { cva, type VariantProps } from 'class-variance-authority'
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8 rounded-md',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>
</script>
<script setup lang="ts">
import { Primitive, type PrimitiveProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<
PrimitiveProps & {
variant?: ButtonVariants['variant']
size?: ButtonVariants['size']
class?: string
}
>(),
{ as: 'button' },
)
</script>
<template>
<Primitive
:as="props.as"
:as-child="props.asChild"
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
>
<slot />
</Primitive>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div
:class="
cn(
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
props.class,
)
"
>
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
<slot />
</p>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
<slot />
</div>
</template>
@@ -0,0 +1,11 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
<slot />
</h3>
</template>
+22
View File
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string | number }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<input
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>
+21
View File
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { Label } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; for?: string }>()
</script>
<template>
<Label
:for="props.for"
:class="
cn(
'flex items-center gap-2 text-sm leading-none font-medium select-none',
props.class,
)
"
>
<slot />
</Label>
</template>
+71
View File
@@ -0,0 +1,71 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import {
DialogClose,
DialogContent,
DialogDescription,
DialogOverlay,
DialogPortal,
DialogRoot,
DialogTitle,
} from 'reka-ui'
import { cn } from '@/lib/utils'
/**
* A dialog that turns into a bottom drawer on small screens, which is much
* easier to reach with a thumb than a centered modal.
*/
const props = defineProps<{
open: boolean
title: string
description?: string
class?: string
}>()
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
</script>
<template>
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
<DialogPortal>
<DialogOverlay
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
/>
<DialogContent
:class="
cn(
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
// Mobile: bottom drawer
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
// Desktop: centered dialog
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
props.class,
)
"
>
<div
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
/>
<div class="flex items-start justify-between gap-4">
<div class="flex flex-col gap-1">
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
{{ props.description }}
</DialogDescription>
</div>
<DialogClose
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
>
<X class="size-4" />
</DialogClose>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<slot />
</div>
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<slot name="footer" />
</div>
</DialogContent>
</DialogPortal>
</DialogRoot>
</template>
+69
View File
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { Check, ChevronDown } from 'lucide-vue-next'
import {
SelectContent,
SelectItem,
SelectItemIndicator,
SelectItemText,
SelectPortal,
SelectRoot,
SelectTrigger,
SelectValue,
SelectViewport,
} from 'reka-ui'
import type { SelectOption } from '@/lib/types'
import { cn } from '@/lib/utils'
const props = defineProps<{
modelValue: string
options: SelectOption[]
placeholder?: string
ariaLabel?: string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<SelectRoot
:model-value="props.modelValue"
@update:model-value="emit('update:modelValue', String($event))"
>
<SelectTrigger
:aria-label="props.ariaLabel"
:class="
cn(
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
>
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
<ChevronDown class="size-4 shrink-0 opacity-50" />
</SelectTrigger>
<SelectPortal>
<SelectContent
position="popper"
:side-offset="4"
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
>
<SelectViewport class="p-1">
<SelectItem
v-for="option in props.options"
:key="option.value"
:value="option.value"
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
>
<SelectItemText>{{ option.label }}</SelectItemText>
<SelectItemIndicator class="absolute right-2 flex items-center">
<Check class="size-4" />
</SelectItemIndicator>
</SelectItem>
</SelectViewport>
</SelectContent>
</SelectPortal>
</SelectRoot>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = withDefaults(
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
{ orientation: 'horizontal' },
)
</script>
<template>
<div
role="separator"
:class="
cn(
'bg-border shrink-0',
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
props.class,
)
"
/>
</template>
@@ -0,0 +1,9 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
</template>
+26
View File
@@ -0,0 +1,26 @@
<script setup lang="ts">
import { SwitchRoot, SwitchThumb } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
</script>
<template>
<SwitchRoot
:id="props.id"
:model-value="props.modelValue"
:class="
cn(
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event)"
>
<SwitchThumb
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
/>
</SwitchRoot>
</template>
+18
View File
@@ -0,0 +1,18 @@
<script setup lang="ts">
import { TabsRoot } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<TabsRoot
:model-value="props.modelValue"
:class="cn('flex flex-col gap-3', props.class)"
@update:model-value="emit('update:modelValue', String($event))"
>
<slot />
</TabsRoot>
</template>
@@ -0,0 +1,13 @@
<script setup lang="ts">
import { TabsContent } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
<slot />
</TabsContent>
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { TabsList } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string }>()
</script>
<template>
<TabsList
:class="
cn(
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
props.class,
)
"
>
<slot />
</TabsList>
</template>
@@ -0,0 +1,24 @@
<script setup lang="ts">
import { TabsTrigger } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; value: string }>()
</script>
<template>
<TabsTrigger
:value="props.value"
:class="
cn(
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
'[&_svg]:size-3.5',
props.class,
)
"
>
<slot />
</TabsTrigger>
</template>
@@ -0,0 +1,73 @@
<script setup lang="ts">
import { X } from 'lucide-vue-next'
import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui'
import { computed } from 'vue'
import { cn } from '@/lib/utils'
/** Chip style input for a list of values, with per-value validation. */
const props = defineProps<{
modelValue: string[]
placeholder?: string
/** Returns an error message for an invalid value, or "" when it is fine. */
validate?: (value: string) => string
class?: string
}>()
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
const invalid = computed(() => {
if (!props.validate) return new Set<string>()
return new Set(props.modelValue.filter((v) => props.validate?.(v)))
})
const errors = computed(() => {
if (!props.validate) return [] as string[]
const messages = new Set<string>()
for (const value of props.modelValue) {
const message = props.validate(value)
if (message) messages.add(message)
}
return [...messages]
})
</script>
<template>
<div class="flex flex-col gap-1.5">
<TagsInputRoot
:model-value="props.modelValue"
:delimiter="','"
:add-on-paste="true"
:class="
cn(
'border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]',
props.class,
)
"
@update:model-value="emit('update:modelValue', $event as string[])"
>
<TagsInputItem
v-for="value in props.modelValue"
:key="value"
:value="value"
:class="
cn(
'flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-xs',
invalid.has(value)
? 'border-destructive/50 bg-destructive/10 text-destructive'
: 'bg-secondary text-secondary-foreground border-transparent',
)
"
>
<TagsInputItemText />
<TagsInputItemDelete class="hover:text-foreground opacity-60 transition-opacity hover:opacity-100">
<X class="size-3" />
</TagsInputItemDelete>
</TagsInputItem>
<TagsInputInput
:placeholder="props.modelValue.length ? '' : props.placeholder"
class="placeholder:text-muted-foreground min-w-24 flex-1 bg-transparent px-1 text-sm outline-none"
/>
</TagsInputRoot>
<p v-for="message in errors" :key="message" class="text-destructive text-xs">{{ message }}</p>
</div>
</template>
@@ -0,0 +1,22 @@
<script setup lang="ts">
import { cn } from '@/lib/utils'
const props = defineProps<{ class?: string; modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<textarea
:value="props.modelValue"
:class="
cn(
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
'placeholder:text-muted-foreground field-sizing-content',
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
'disabled:cursor-not-allowed disabled:opacity-50',
props.class,
)
"
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>
+20
View File
@@ -0,0 +1,20 @@
export { default as Badge, badgeVariants } from './Badge.vue'
export { default as Button, buttonVariants } from './Button.vue'
export { default as Card } from './Card.vue'
export { default as CardContent } from './CardContent.vue'
export { default as CardDescription } from './CardDescription.vue'
export { default as CardHeader } from './CardHeader.vue'
export { default as CardTitle } from './CardTitle.vue'
export { default as Input } from './Input.vue'
export { default as Label } from './Label.vue'
export { default as Modal } from './Modal.vue'
export { default as Select } from './Select.vue'
export { default as Separator } from './Separator.vue'
export { default as Skeleton } from './Skeleton.vue'
export { default as Switch } from './Switch.vue'
export { default as Tabs } from './Tabs.vue'
export { default as TagsInput } from './TagsInput.vue'
export { default as TabsContent } from './TabsContent.vue'
export { default as TabsList } from './TabsList.vue'
export { default as TabsTrigger } from './TabsTrigger.vue'
export { default as Textarea } from './Textarea.vue'
+35
View File
@@ -0,0 +1,35 @@
import { ref } from 'vue'
import { api } from '@/lib/api'
import type { GeoData } from '@/lib/types'
const geo = ref<GeoData | null>(null)
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
const loading = ref(false)
let pending: Promise<void> | null = null
/** Loads the geo database listing once and shares it across components. */
async function load(force = false): Promise<void> {
if (geo.value && !force) return
if (pending) return pending
loading.value = true
pending = (async () => {
try {
const data = await api.geo(force)
geo.value = data
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
} catch (e) {
const message = e instanceof Error ? e.message : String(e)
error.value = { ip: message, site: message }
geo.value = { ip: [], site: [] }
} finally {
loading.value = false
pending = null
}
})()
return pending
}
export function useGeo() {
return { geo, error, loading, load }
}
+105
View File
@@ -0,0 +1,105 @@
import { computed, ref, shallowRef } from 'vue'
import { api, auth, liveUrl } from '@/lib/api'
import type { LiveEvent, Metrics } from '@/lib/types'
const MAX_EVENTS = 500
export type LiveStatus = 'closed' | 'connecting' | 'open'
const metrics = ref<Metrics | null>(null)
const events = shallowRef<LiveEvent[]>([])
const status = ref<LiveStatus>('closed')
let source: EventSource | null = null
let retryTimer: number | undefined
let retryDelay = 1000
function pushEvent(event: LiveEvent) {
const next = [event, ...events.value]
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
events.value = next
}
async function seed() {
try {
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
metrics.value = m
if (events.value.length === 0) events.value = e.events ?? []
} catch {
// The SSE connection will report the problem, no need to duplicate it.
}
}
function connect() {
if (source || !auth.authenticated) return
status.value = 'connecting'
const es = new EventSource(liveUrl)
source = es
es.addEventListener('open', () => {
status.value = 'open'
retryDelay = 1000
})
es.addEventListener('metrics', (ev) => {
try {
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
status.value = 'open'
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('event', (ev) => {
try {
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
} catch {
/* ignore malformed frame */
}
})
es.addEventListener('error', () => {
es.close()
if (source === es) source = null
status.value = 'closed'
// The session may have expired; a plain fetch tells us for sure.
window.clearTimeout(retryTimer)
retryTimer = window.setTimeout(() => {
if (auth.authenticated) connect()
}, retryDelay)
retryDelay = Math.min(retryDelay * 2, 15000)
})
}
export function startLive() {
if (!auth.authenticated) return
void seed()
connect()
}
export function stopLive() {
window.clearTimeout(retryTimer)
source?.close()
source = null
status.value = 'closed'
metrics.value = null
events.value = []
}
export function clearEvents() {
events.value = []
}
export function useLive() {
return {
metrics,
events,
status,
connected: computed(() => status.value === 'open'),
startLive,
stopLive,
clearEvents,
refresh: seed,
}
}
+46
View File
@@ -0,0 +1,46 @@
import { computed, ref, watch } from 'vue'
export type Theme = 'light' | 'dark' | 'system'
const THEME_KEY = 'opengfw.theme'
const stored = localStorage.getItem(THEME_KEY)
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
const media = window.matchMedia('(prefers-color-scheme: dark)')
function resolve(value: Theme): boolean {
return value === 'dark' || (value === 'system' && media.matches)
}
const isDark = ref(resolve(theme.value))
function apply() {
isDark.value = resolve(theme.value)
document.documentElement.classList.toggle('dark', isDark.value)
document
.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
}
watch(theme, (value) => {
localStorage.setItem(THEME_KEY, value)
apply()
})
media.addEventListener('change', () => {
if (theme.value === 'system') apply()
})
apply()
export function useTheme() {
return {
theme,
isDark: computed(() => isDark.value),
setTheme: (value: Theme) => {
theme.value = value
},
toggle: () => {
theme.value = isDark.value ? 'light' : 'dark'
},
}
}
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue'
export type ToastVariant = 'default' | 'success' | 'error'
export interface Toast {
id: number
title: string
description?: string
variant: ToastVariant
}
const toasts = ref<Toast[]>([])
let nextId = 1
function push(title: string, variant: ToastVariant, description?: string) {
const id = nextId++
toasts.value = [...toasts.value, { id, title, description, variant }]
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
}
export function dismiss(id: number) {
toasts.value = toasts.value.filter((t) => t.id !== id)
}
export const toast = {
info: (title: string, description?: string) => push(title, 'default', description),
success: (title: string, description?: string) => push(title, 'success', description),
error: (title: string, description?: string) => push(title, 'error', description),
}
export function useToast() {
return { toasts, toast, dismiss }
}

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