Compare commits
45
Commits
v0.2.3
...
feat/web-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1c9daac28d | ||
|
|
a6917146b8 | ||
|
|
f7ad3aac95 | ||
|
|
5a7722d1d2 | ||
|
|
393c29bd2d | ||
|
|
9c0893c512 | ||
|
|
ae34b4856a | ||
|
|
d7737e9211 | ||
|
|
dd9ecc3dd7 | ||
|
|
980d59ed2e | ||
|
|
af14adf313 | ||
|
|
ab28fc238d | ||
|
|
e535769086 | ||
|
|
ecd60d0ff1 | ||
|
|
98264d9e27 | ||
|
|
bb5d4e32ff | ||
|
|
ca574393d3 | ||
|
|
0e2ee36865 | ||
|
|
b02738cde8 | ||
|
|
0735fa831d | ||
|
|
2232b553b3 | ||
|
|
b2f6dec909 | ||
|
|
47a3c9875c | ||
|
|
4e604904af | ||
|
|
bf2988116a | ||
|
|
ef1416274d | ||
|
|
57c818038c | ||
|
|
6ad7714c9a | ||
|
|
ff9c4ccf79 | ||
|
|
e1d9406fdb | ||
|
|
b8e5079b8a | ||
|
|
f3b72895ad | ||
|
|
0732dfa7a5 | ||
|
|
9d96acd8db | ||
|
|
d1775184ce | ||
|
|
05d56616fc | ||
|
|
ede70e1a87 | ||
|
|
920783bd65 | ||
|
|
3a45461c19 | ||
|
|
3022bde81b | ||
|
|
d98136bac7 | ||
|
|
c0e2483f6c | ||
|
|
3bd02ed46e | ||
|
|
4257788f33 | ||
|
|
e77c2fabea |
@@ -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 ./...
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
name: Release
|
||||||
on:
|
on:
|
||||||
release:
|
release:
|
||||||
types: [published]
|
types: [published]
|
||||||
@@ -14,13 +15,37 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
goos: [linux]
|
goos: [linux]
|
||||||
goarch: ["386", amd64, arm64]
|
goarch: ["386", amd64, arm64]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: wangyoucao577/go-release-action@v1
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
go-version: "1.22"
|
||||||
goos: ${{ matrix.goos }}
|
|
||||||
goarch: ${{ matrix.goarch }}
|
- name: Setup Node
|
||||||
goversion: "https://go.dev/dl/go1.22.0.linux-amd64.tar.gz"
|
uses: actions/setup-node@v4
|
||||||
binary_name: "OpenGFW"
|
with:
|
||||||
extra_files: LICENSE README.md README.zh.md
|
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
@@ -205,3 +205,16 @@ $RECYCLE.BIN/
|
|||||||
*.lnk
|
*.lnk
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,go,goland+all,visualstudiocode
|
# 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/
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
|
[](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
|
||||||
[![License][1]][2]
|
[![License][1]][2]
|
||||||
|
|
||||||
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
|
[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 上で利用可能な柔軟で使いやすいオープンソースプログラムとして提供されています。なぜ権力者だけが楽しむのでしょうか?権力を人々に与え、検閲を民主化する時が来ました。自宅のルーターにサイバー主権のスリルをもたらし、プロのようにフィルタリングを始めましょう - あなたもビッグブラザーになることができます。
|
OpenGFW は、あなた専用の DIY 中国のグレートファイアウォール (https://en.wikipedia.org/wiki/Great_Firewall) です。Linux 上で利用可能な柔軟で使いやすいオープンソースプログラムとして提供されています。なぜ権力者だけが楽しむのでしょうか?権力を人々に与え、検閲を民主化する時が来ました。自宅のルーターにサイバー主権のスリルをもたらし、プロのようにフィルタリングを始めましょう - あなたもビッグブラザーになることができます。
|
||||||
|
|
||||||
|
**ドキュメントウェブサイト: https://gfw.dev/**
|
||||||
|
|
||||||
Telegram グループ: https://t.me/OpGFW
|
Telegram グループ: https://t.me/OpGFW
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> このプロジェクトはまだ開発の初期段階です。使用は自己責任でお願いします。
|
> 本プロジェクトはまだ初期開発段階にあります。テスト時のリスクは自己責任でお願いします。私たちは、このプロジェクトを一緒に改善するために貢献者を探しています。
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> 私たちはこのプロジェクト、特により多くのプロトコル用のアナライザーの実装を手伝ってくれるコントリビューターを探しています!!!
|
|
||||||
|
|
||||||
## 特徴
|
## 特徴
|
||||||
|
|
||||||
- フル IP/TCP 再アセンブル、各種プロトコルアナライザー
|
- フル IP/TCP 再アセンブル、各種プロトコルアナライザー
|
||||||
- HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、その他多数
|
- HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、OpenVPN、その他多数
|
||||||
- Shadowsocks の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/)
|
- Shadowsocks、VMess の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/)
|
||||||
- トロイの木馬キラー (https://github.com/XTLS/Trojan-killer) に基づくトロイの木馬 (プロキシプロトコル) 検出
|
- Trojan プロキシプロトコルの検出
|
||||||
- [WIP] 機械学習に基づくトラフィック分類
|
- [WIP] 機械学習に基づくトラフィック分類
|
||||||
- IPv4 と IPv6 をフルサポート
|
- IPv4 と IPv6 をフルサポート
|
||||||
- フローベースのマルチコア負荷分散
|
- フローベースのマルチコア負荷分散
|
||||||
@@ -29,7 +29,32 @@ Telegram グループ: https://t.me/OpGFW
|
|||||||
- ルールのホットリロード (`SIGHUP` を送信してリロード)
|
- ルールのホットリロード (`SIGHUP` を送信してリロード)
|
||||||
- 柔軟なアナライザ&モディファイアフレームワーク
|
- 柔軟なアナライザ&モディファイアフレームワーク
|
||||||
- 拡張可能な IO 実装 (今のところ NFQueue のみ)
|
- 拡張可能な 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/プロキシサービスの不正利用防止
|
- 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` と同じ。
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
|
[](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
|
||||||
[![License][1]][2]
|
[![License][1]][2]
|
||||||
|
|
||||||
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
|
[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.
|
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
|
Telegram group: https://t.me/OpGFW
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> This project is still in very early stages of development. Use at your own risk.
|
> 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.
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> We are looking for contributors to help us with this project, especially implementing analyzers for more protocols!!!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Full IP/TCP reassembly, various protocol analyzers
|
- Full IP/TCP reassembly, various protocol analyzers
|
||||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, and many more to come
|
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, and many more to come
|
||||||
- "Fully encrypted traffic" detection for Shadowsocks,
|
- "Fully encrypted traffic" detection for Shadowsocks, VMess,
|
||||||
etc. (https://gfw.report/publications/usenixsecurity23/en/)
|
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
|
- [WIP] Machine learning based traffic classification
|
||||||
- Full IPv4 and IPv6 support
|
- Full IPv4 and IPv6 support
|
||||||
- Flow-based multicore load balancing
|
- Flow-based multicore load balancing
|
||||||
@@ -33,7 +33,33 @@ Telegram group: https://t.me/OpGFW
|
|||||||
- Hot-reloadable rules (send `SIGHUP` to reload)
|
- Hot-reloadable rules (send `SIGHUP` to reload)
|
||||||
- Flexible analyzer & modifier framework
|
- Flexible analyzer & modifier framework
|
||||||
- Extensible IO implementation (only NFQueue for now)
|
- 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
|
## Use cases
|
||||||
|
|
||||||
@@ -43,124 +69,3 @@ Telegram group: https://t.me/OpGFW
|
|||||||
- Abuse prevention for VPN/proxy services
|
- Abuse prevention for VPN/proxy services
|
||||||
- Traffic analysis (log only mode)
|
- Traffic analysis (log only mode)
|
||||||
- Help you fulfill your dictatorial ambitions
|
- 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
@@ -1,5 +1,6 @@
|
|||||||
# 
|
# 
|
||||||
|
|
||||||
|
[](https://github.com/apernet/OpenGFW/actions/workflows/check.yaml)
|
||||||
[![License][1]][2]
|
[![License][1]][2]
|
||||||
|
|
||||||
[1]: https://img.shields.io/badge/License-MPL_2.0-brightgreen.svg
|
[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 更强大。为何让那些掌权者独享乐趣?是时候把权力归还给人民,人人有墙建了。立即安装可以部署在家用路由器上的网络主权 - 你也能是老大哥。
|
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
|
Telegram 群组: https://t.me/OpGFW
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> 本项目仍处于早期开发阶段。测试时自行承担风险。
|
> 本项目仍处于早期开发阶段。测试时自行承担风险。我们正在寻求贡献者一起完善本项目。
|
||||||
|
|
||||||
> [!NOTE]
|
|
||||||
> 我们正在寻求贡献者一起完善本项目,尤其是实现更多协议的解析器!
|
|
||||||
|
|
||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 完整的 IP/TCP 重组,各种协议解析器
|
- 完整的 IP/TCP 重组,各种协议解析器
|
||||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, 更多协议正在开发中
|
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, 更多协议正在开发中
|
||||||
- Shadowsocks 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
|
- Shadowsocks, VMess 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
|
||||||
- 基于 Trojan-killer 的 Trojan 检测 (https://github.com/XTLS/Trojan-killer)
|
- Trojan 协议检测
|
||||||
- [开发中] 基于机器学习的流量分类
|
- [开发中] 基于机器学习的流量分类
|
||||||
- 同等支持 IPv4 和 IPv6
|
- 同等支持 IPv4 和 IPv6
|
||||||
- 基于流的多核负载均衡
|
- 基于流的多核负载均衡
|
||||||
@@ -29,7 +29,30 @@ Telegram 群组: https://t.me/OpGFW
|
|||||||
- 规则可以热重载 (发送 `SIGHUP` 信号)
|
- 规则可以热重载 (发送 `SIGHUP` 信号)
|
||||||
- 灵活的协议解析和修改框架
|
- 灵活的协议解析和修改框架
|
||||||
- 可扩展的 IO 实现 (目前只有 NFQueue)
|
- 可扩展的 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/代理服务滥用防护
|
- 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`。
|
|
||||||
|
|||||||
@@ -5,7 +5,26 @@ import (
|
|||||||
"github.com/apernet/OpenGFW/analyzer/utils"
|
"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
|
var ok bool
|
||||||
m := make(analyzer.PropMap)
|
m := make(analyzer.PropMap)
|
||||||
// Version, random & session ID length combined are within 35 bytes,
|
// Version, random & session ID length combined are within 35 bytes,
|
||||||
@@ -76,7 +95,7 @@ func ParseTLSClientHello(chBuf *utils.ByteBuffer) analyzer.PropMap {
|
|||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseTLSServerHello(shBuf *utils.ByteBuffer) analyzer.PropMap {
|
func ParseTLSServerHelloMsgData(shBuf *utils.ByteBuffer) analyzer.PropMap {
|
||||||
var ok bool
|
var ok bool
|
||||||
m := make(analyzer.PropMap)
|
m := make(analyzer.PropMap)
|
||||||
// Version, random & session ID length combined are within 35 bytes,
|
// 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 {
|
func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer.PropMap) bool {
|
||||||
switch extType {
|
switch extType {
|
||||||
case 0x0000: // SNI
|
case extServerName:
|
||||||
ok := extDataBuf.Skip(2) // Ignore list length, we only care about the first entry for now
|
ok := extDataBuf.Skip(2) // Ignore list length, we only care about the first entry for now
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not enough data for list length
|
// Not enough data for list length
|
||||||
@@ -154,7 +173,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
|
|||||||
// Not enough data for SNI
|
// Not enough data for SNI
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
case 0x0010: // ALPN
|
case extALPN:
|
||||||
ok := extDataBuf.Skip(2) // Ignore list length, as we read until the end
|
ok := extDataBuf.Skip(2) // Ignore list length, as we read until the end
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not enough data for list length
|
// Not enough data for list length
|
||||||
@@ -175,7 +194,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
|
|||||||
alpnList = append(alpnList, alpn)
|
alpnList = append(alpnList, alpn)
|
||||||
}
|
}
|
||||||
m["alpn"] = alpnList
|
m["alpn"] = alpnList
|
||||||
case 0x002b: // Supported Versions
|
case extSupportedVersions:
|
||||||
if extDataBuf.Len() == 2 {
|
if extDataBuf.Len() == 2 {
|
||||||
// Server only selects one version
|
// Server only selects one version
|
||||||
m["supported_versions"], _ = extDataBuf.GetUint16(false, true)
|
m["supported_versions"], _ = extDataBuf.GetUint16(false, true)
|
||||||
@@ -197,7 +216,7 @@ func parseTLSExtensions(extType uint16, extDataBuf *utils.ByteBuffer, m analyzer
|
|||||||
}
|
}
|
||||||
m["supported_versions"] = versions
|
m["supported_versions"] = versions
|
||||||
}
|
}
|
||||||
case 0xfe0d: // ECH
|
case extEncryptedClientHello:
|
||||||
// We can't parse ECH for now, just set a flag
|
// We can't parse ECH for now, just set a flag
|
||||||
m["ech"] = true
|
m["ech"] = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -208,10 +208,10 @@ func (s *socksStream) parseSocks5ReqMethod() utils.LSMAction {
|
|||||||
switch method {
|
switch method {
|
||||||
case Socks5AuthNotRequired:
|
case Socks5AuthNotRequired:
|
||||||
s.authReqMethod = Socks5AuthNotRequired
|
s.authReqMethod = Socks5AuthNotRequired
|
||||||
break
|
return utils.LSMActionNext
|
||||||
case Socks5AuthPassword:
|
case Socks5AuthPassword:
|
||||||
s.authReqMethod = Socks5AuthPassword
|
s.authReqMethod = Socks5AuthPassword
|
||||||
break
|
return utils.LSMActionNext
|
||||||
default:
|
default:
|
||||||
// TODO: more auth method to support
|
// TODO: more auth method to support
|
||||||
}
|
}
|
||||||
|
|||||||
+84
-36
@@ -44,12 +44,12 @@ type tlsStream struct {
|
|||||||
func newTLSStream(logger analyzer.Logger) *tlsStream {
|
func newTLSStream(logger analyzer.Logger) *tlsStream {
|
||||||
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}}
|
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}}
|
||||||
s.reqLSM = utils.NewLinearStateMachine(
|
s.reqLSM = utils.NewLinearStateMachine(
|
||||||
s.tlsClientHelloSanityCheck,
|
s.tlsClientHelloPreprocess,
|
||||||
s.parseClientHello,
|
s.parseClientHelloData,
|
||||||
)
|
)
|
||||||
s.respLSM = utils.NewLinearStateMachine(
|
s.respLSM = utils.NewLinearStateMachine(
|
||||||
s.tlsServerHelloSanityCheck,
|
s.tlsServerHelloPreprocess,
|
||||||
s.parseServerHello,
|
s.parseServerHelloData,
|
||||||
)
|
)
|
||||||
return s
|
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)
|
return update, cancelled || (s.reqDone && s.respDone)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tlsStream) tlsClientHelloSanityCheck() utils.LSMAction {
|
// tlsClientHelloPreprocess validates ClientHello message.
|
||||||
data, ok := s.reqBuf.Get(9, true)
|
//
|
||||||
|
// 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 {
|
if !ok {
|
||||||
|
// not a full header yet
|
||||||
return utils.LSMActionPause
|
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
|
return utils.LSMActionCancel
|
||||||
}
|
}
|
||||||
s.clientHelloLen = int(data[6])<<16 | int(data[7])<<8 | int(data[8])
|
|
||||||
if s.clientHelloLen < 41 {
|
s.clientHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
|
||||||
// 2 (Protocol Version) +
|
if s.clientHelloLen < minDataSize {
|
||||||
// 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
|
|
||||||
return utils.LSMActionCancel
|
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
|
return utils.LSMActionNext
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tlsStream) tlsServerHelloSanityCheck() utils.LSMAction {
|
// tlsServerHelloPreprocess validates ServerHello message.
|
||||||
data, ok := s.respBuf.Get(9, true)
|
//
|
||||||
|
// 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 {
|
if !ok {
|
||||||
|
// not a full header yet
|
||||||
return utils.LSMActionPause
|
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
|
return utils.LSMActionCancel
|
||||||
}
|
}
|
||||||
s.serverHelloLen = int(data[6])<<16 | int(data[7])<<8 | int(data[8])
|
|
||||||
if s.serverHelloLen < 38 {
|
s.serverHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
|
||||||
// 2 (Protocol Version) +
|
if s.serverHelloLen < minDataSize {
|
||||||
// 32 (Random) +
|
|
||||||
// 1 (Session ID Length) +
|
|
||||||
// 2 (Cipher Suite) +
|
|
||||||
// 1 (Compression Method) +
|
|
||||||
// No extensions
|
|
||||||
// This should be the bare minimum for a server hello
|
|
||||||
return utils.LSMActionCancel
|
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
|
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)
|
chBuf, ok := s.reqBuf.GetSubBuffer(s.clientHelloLen, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not a full client hello yet
|
// Not a full client hello yet
|
||||||
return utils.LSMActionPause
|
return utils.LSMActionPause
|
||||||
}
|
}
|
||||||
m := internal.ParseTLSClientHello(chBuf)
|
m := internal.ParseTLSClientHelloMsgData(chBuf)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return utils.LSMActionCancel
|
return utils.LSMActionCancel
|
||||||
} else {
|
} 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)
|
shBuf, ok := s.respBuf.GetSubBuffer(s.serverHelloLen, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
// Not a full server hello yet
|
// Not a full server hello yet
|
||||||
return utils.LSMActionPause
|
return utils.LSMActionPause
|
||||||
}
|
}
|
||||||
m := internal.ParseTLSServerHello(shBuf)
|
m := internal.ParseTLSServerHelloMsgData(shBuf)
|
||||||
if m == nil {
|
if m == nil {
|
||||||
return utils.LSMActionCancel
|
return utils.LSMActionCancel
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+463
-37
@@ -9,22 +9,15 @@ import (
|
|||||||
var _ analyzer.TCPAnalyzer = (*TrojanAnalyzer)(nil)
|
var _ analyzer.TCPAnalyzer = (*TrojanAnalyzer)(nil)
|
||||||
|
|
||||||
// CCS stands for "Change Cipher Spec"
|
// 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 (
|
// TrojanAnalyzer uses length-based heuristics to detect Trojan traffic based on
|
||||||
trojanUpLB = 650
|
// its "TLS-in-TLS" nature. The heuristics are trained using a decision tree with
|
||||||
trojanUpUB = 1000
|
// about 20k Trojan samples and 30k non-Trojan samples. The tree is then converted
|
||||||
trojanDownLB1 = 170
|
// to code using a custom tool and inlined here (isTrojanSeq function).
|
||||||
trojanDownUB1 = 180
|
// Accuracy: 1% false positive rate, 10% false negative rate.
|
||||||
trojanDownLB2 = 3000
|
// We do NOT recommend directly blocking all positive connections, as this may
|
||||||
trojanDownUB2 = 7500
|
// break legitimate TLS connections.
|
||||||
)
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
type TrojanAnalyzer struct{}
|
type TrojanAnalyzer struct{}
|
||||||
|
|
||||||
func (a *TrojanAnalyzer) Name() string {
|
func (a *TrojanAnalyzer) Name() string {
|
||||||
@@ -32,7 +25,7 @@ func (a *TrojanAnalyzer) Name() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *TrojanAnalyzer) Limit() int {
|
func (a *TrojanAnalyzer) Limit() int {
|
||||||
return 16384
|
return 512000
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *TrojanAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream {
|
func (a *TrojanAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream {
|
||||||
@@ -40,10 +33,12 @@ func (a *TrojanAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) a
|
|||||||
}
|
}
|
||||||
|
|
||||||
type trojanStream struct {
|
type trojanStream struct {
|
||||||
logger analyzer.Logger
|
logger analyzer.Logger
|
||||||
active bool
|
first bool
|
||||||
upCount int
|
count bool
|
||||||
downCount int
|
rev bool
|
||||||
|
seq [4]int
|
||||||
|
seqIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func newTrojanStream(logger analyzer.Logger) *trojanStream {
|
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 {
|
if len(data) == 0 {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if !rev && !s.active && len(data) >= 6 && bytes.Equal(data[:6], trojanCCS) {
|
|
||||||
// Client CCS encountered, start counting
|
if s.first {
|
||||||
s.active = true
|
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 {
|
if !rev && !s.count && len(data) >= 6 && bytes.Equal(data[:6], ccsPattern) {
|
||||||
// Down direction
|
// Client Change Cipher Spec encountered, start counting
|
||||||
s.downCount += len(data)
|
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 {
|
} else {
|
||||||
// Up direction
|
// Different direction, bump the index
|
||||||
if s.upCount >= trojanUpLB && s.upCount <= trojanUpUB &&
|
s.seqIndex += 1
|
||||||
((s.downCount >= trojanDownLB1 && s.downCount <= trojanDownUB1) ||
|
if s.seqIndex == 4 {
|
||||||
(s.downCount >= trojanDownLB2 && s.downCount <= trojanDownUB2)) {
|
|
||||||
return &analyzer.PropUpdate{
|
return &analyzer.PropUpdate{
|
||||||
Type: analyzer.PropUpdateReplace,
|
Type: analyzer.PropUpdateReplace,
|
||||||
M: analyzer.PropMap{
|
M: analyzer.PropMap{
|
||||||
"up": s.upCount,
|
"seq": s.seq,
|
||||||
"down": s.downCount,
|
"yes": isTrojanSeq(s.seq),
|
||||||
"yes": true,
|
|
||||||
},
|
},
|
||||||
}, true
|
}, 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 {
|
func (s *trojanStream) Close(limited bool) *analyzer.PropUpdate {
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -36,41 +36,40 @@ type quicStream struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *quicStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, done bool) {
|
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 {
|
if rev {
|
||||||
// We don't support server direction for now
|
// We don't support server direction for now
|
||||||
s.invalidCount++
|
s.invalidCount++
|
||||||
return nil, s.invalidCount >= quicInvalidCountThreshold
|
return nil, s.invalidCount >= quicInvalidCountThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
pl, err := quic.ReadCryptoPayload(data)
|
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++
|
s.invalidCount++
|
||||||
return nil, s.invalidCount >= quicInvalidCountThreshold
|
return nil, s.invalidCount >= quicInvalidCountThreshold
|
||||||
}
|
}
|
||||||
// Should be a TLS client hello
|
|
||||||
if pl[0] != 0x01 {
|
if pl[0] != internal.TypeClientHello {
|
||||||
// Not a client hello
|
|
||||||
s.invalidCount++
|
s.invalidCount++
|
||||||
return nil, s.invalidCount >= quicInvalidCountThreshold
|
return nil, s.invalidCount >= quicInvalidCountThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
chLen := int(pl[1])<<16 | int(pl[2])<<8 | int(pl[3])
|
chLen := int(pl[1])<<16 | int(pl[2])<<8 | int(pl[3])
|
||||||
if chLen < 41 {
|
if chLen < minDataSize {
|
||||||
// 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.invalidCount++
|
s.invalidCount++
|
||||||
return nil, s.invalidCount >= quicInvalidCountThreshold
|
return nil, s.invalidCount >= quicInvalidCountThreshold
|
||||||
}
|
}
|
||||||
m := internal.ParseTLSClientHello(&utils.ByteBuffer{Buf: pl[4:]})
|
|
||||||
|
m := internal.ParseTLSClientHelloMsgData(&utils.ByteBuffer{Buf: pl[4:]})
|
||||||
if m == nil {
|
if m == nil {
|
||||||
s.invalidCount++
|
s.invalidCount++
|
||||||
return nil, s.invalidCount >= quicInvalidCountThreshold
|
return nil, s.invalidCount >= quicInvalidCountThreshold
|
||||||
}
|
}
|
||||||
|
|
||||||
return &analyzer.PropUpdate{
|
return &analyzer.PropUpdate{
|
||||||
Type: analyzer.PropUpdateMerge,
|
Type: analyzer.PropUpdateMerge,
|
||||||
M: analyzer.PropMap{"req": m},
|
M: analyzer.PropMap{"req": m},
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+81
-39
@@ -5,7 +5,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
@@ -17,6 +16,7 @@ import (
|
|||||||
"github.com/apernet/OpenGFW/modifier"
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||||
"github.com/apernet/OpenGFW/ruleset"
|
"github.com/apernet/OpenGFW/ruleset"
|
||||||
|
"github.com/apernet/OpenGFW/web"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
@@ -37,6 +37,12 @@ const (
|
|||||||
appLogFormatEnv = "OPENGFW_LOG_FORMAT"
|
appLogFormatEnv = "OPENGFW_LOG_FORMAT"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Build information, set with -ldflags at build time.
|
||||||
|
var (
|
||||||
|
appVersion = "dev"
|
||||||
|
appCommit = ""
|
||||||
|
)
|
||||||
|
|
||||||
var logger *zap.Logger
|
var logger *zap.Logger
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
@@ -93,6 +99,7 @@ var analyzers = []analyzer.Analyzer{
|
|||||||
&tcp.TLSAnalyzer{},
|
&tcp.TLSAnalyzer{},
|
||||||
&tcp.TrojanAnalyzer{},
|
&tcp.TrojanAnalyzer{},
|
||||||
&udp.DNSAnalyzer{},
|
&udp.DNSAnalyzer{},
|
||||||
|
&udp.OpenVPNAnalyzer{},
|
||||||
&udp.QUICAnalyzer{},
|
&udp.QUICAnalyzer{},
|
||||||
&udp.WireGuardAnalyzer{},
|
&udp.WireGuardAnalyzer{},
|
||||||
}
|
}
|
||||||
@@ -165,6 +172,7 @@ type cliConfig struct {
|
|||||||
IO cliConfigIO `mapstructure:"io"`
|
IO cliConfigIO `mapstructure:"io"`
|
||||||
Workers cliConfigWorkers `mapstructure:"workers"`
|
Workers cliConfigWorkers `mapstructure:"workers"`
|
||||||
Ruleset cliConfigRuleset `mapstructure:"ruleset"`
|
Ruleset cliConfigRuleset `mapstructure:"ruleset"`
|
||||||
|
Web cliConfigWeb `mapstructure:"web"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cliConfigIO struct {
|
type cliConfigIO struct {
|
||||||
@@ -172,6 +180,7 @@ type cliConfigIO struct {
|
|||||||
ReadBuffer int `mapstructure:"rcvBuf"`
|
ReadBuffer int `mapstructure:"rcvBuf"`
|
||||||
WriteBuffer int `mapstructure:"sndBuf"`
|
WriteBuffer int `mapstructure:"sndBuf"`
|
||||||
Local bool `mapstructure:"local"`
|
Local bool `mapstructure:"local"`
|
||||||
|
RST bool `mapstructure:"rst"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type cliConfigWorkers struct {
|
type cliConfigWorkers struct {
|
||||||
@@ -187,6 +196,14 @@ type cliConfigRuleset struct {
|
|||||||
GeoSite string `mapstructure:"geosite"`
|
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 {
|
func (c *cliConfig) fillLogger(config *engine.Config) error {
|
||||||
config.Logger = &engineLogger{}
|
config.Logger = &engineLogger{}
|
||||||
return nil
|
return nil
|
||||||
@@ -198,11 +215,12 @@ func (c *cliConfig) fillIO(config *engine.Config) error {
|
|||||||
ReadBuffer: c.IO.ReadBuffer,
|
ReadBuffer: c.IO.ReadBuffer,
|
||||||
WriteBuffer: c.IO.WriteBuffer,
|
WriteBuffer: c.IO.WriteBuffer,
|
||||||
Local: c.IO.Local,
|
Local: c.IO.Local,
|
||||||
|
RST: c.IO.RST,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return configError{Field: "io", Err: err}
|
return configError{Field: "io", Err: err}
|
||||||
}
|
}
|
||||||
config.IOs = []io.PacketIO{nfio}
|
config.IO = nfio
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,24 +263,26 @@ func runMain(cmd *cobra.Command, args []string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("failed to parse config", zap.Error(err))
|
logger.Fatal("failed to parse config", zap.Error(err))
|
||||||
}
|
}
|
||||||
defer func() {
|
defer engineConfig.IO.Close() // Make sure to close IO on exit
|
||||||
// Make sure to close all IOs on exit
|
|
||||||
for _, i := range engineConfig.IOs {
|
// Statistics hub for the web UI
|
||||||
_ = i.Close()
|
if config.Web.Enabled {
|
||||||
}
|
hub = web.NewHub()
|
||||||
}()
|
}
|
||||||
|
|
||||||
// Ruleset
|
// Ruleset
|
||||||
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
|
rsConfig := &ruleset.BuiltinConfig{
|
||||||
|
Logger: &rulesetLogger{},
|
||||||
|
GeoSiteFilename: config.Ruleset.GeoSite,
|
||||||
|
GeoIpFilename: config.Ruleset.GeoIp,
|
||||||
|
ProtectedDialContext: engineConfig.IO.ProtectedDialContext,
|
||||||
|
}
|
||||||
|
rm := newRuleManager(args[0], analyzers, modifiers, rsConfig)
|
||||||
|
rawRules, err := os.ReadFile(args[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("failed to load rules", zap.Error(err))
|
logger.Fatal("failed to load rules", zap.Error(err))
|
||||||
}
|
}
|
||||||
rsConfig := &ruleset.BuiltinConfig{
|
rs, _, err := rm.Compile(string(rawRules))
|
||||||
Logger: &rulesetLogger{},
|
|
||||||
GeoSiteFilename: config.Ruleset.GeoSite,
|
|
||||||
GeoIpFilename: config.Ruleset.GeoIp,
|
|
||||||
}
|
|
||||||
rs, err := ruleset.CompileExprRules(rawRs, analyzers, modifiers, rsConfig)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("failed to compile rules", zap.Error(err))
|
logger.Fatal("failed to compile rules", zap.Error(err))
|
||||||
}
|
}
|
||||||
@@ -273,43 +293,40 @@ func runMain(cmd *cobra.Command, args []string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Fatal("failed to initialize engine", zap.Error(err))
|
logger.Fatal("failed to initialize engine", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
rm.SetEngine(en)
|
||||||
|
|
||||||
// Signal handling
|
// Signal handling
|
||||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||||
go func() {
|
go func() {
|
||||||
// Graceful shutdown
|
// Graceful shutdown
|
||||||
shutdownChan := make(chan os.Signal)
|
shutdownChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(shutdownChan, os.Interrupt, os.Kill)
|
signal.Notify(shutdownChan, os.Interrupt, syscall.SIGTERM)
|
||||||
<-shutdownChan
|
<-shutdownChan
|
||||||
logger.Info("shutting down gracefully...")
|
logger.Info("shutting down gracefully...")
|
||||||
cancelFunc()
|
cancelFunc()
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
// Rule reload
|
// Rule reload
|
||||||
reloadChan := make(chan os.Signal)
|
reloadChan := make(chan os.Signal, 1)
|
||||||
signal.Notify(reloadChan, syscall.SIGHUP)
|
signal.Notify(reloadChan, syscall.SIGHUP)
|
||||||
for {
|
for {
|
||||||
<-reloadChan
|
<-reloadChan
|
||||||
logger.Info("reloading rules")
|
logger.Info("reloading rules")
|
||||||
rawRs, err := ruleset.ExprRulesFromYAML(args[0])
|
if err := rm.Reload(); err != nil {
|
||||||
if err != nil {
|
logger.Error("failed to reload rules, using old rules", zap.Error(err))
|
||||||
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))
|
|
||||||
} else {
|
} else {
|
||||||
logger.Info("rules reloaded")
|
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 started")
|
||||||
logger.Info("engine exited", zap.Error(en.Run(ctx)))
|
logger.Info("engine exited", zap.Error(en.Run(ctx)))
|
||||||
}
|
}
|
||||||
@@ -317,14 +334,23 @@ func runMain(cmd *cobra.Command, args []string) {
|
|||||||
type engineLogger struct{}
|
type engineLogger struct{}
|
||||||
|
|
||||||
func (l *engineLogger) WorkerStart(id int) {
|
func (l *engineLogger) WorkerStart(id int) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.WorkerStarted()
|
||||||
|
}
|
||||||
logger.Debug("worker started", zap.Int("id", id))
|
logger.Debug("worker started", zap.Int("id", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *engineLogger) WorkerStop(id int) {
|
func (l *engineLogger) WorkerStop(id int) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.WorkerStopped()
|
||||||
|
}
|
||||||
logger.Debug("worker stopped", zap.Int("id", id))
|
logger.Debug("worker stopped", zap.Int("id", id))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
|
func (l *engineLogger) TCPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.StreamNew("tcp")
|
||||||
|
}
|
||||||
logger.Debug("new TCP stream",
|
logger.Debug("new TCP stream",
|
||||||
zap.Int("workerID", workerID),
|
zap.Int("workerID", workerID),
|
||||||
zap.Int64("id", info.ID),
|
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) {
|
func (l *engineLogger) TCPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.PropUpdate(toWebProps(info.Props))
|
||||||
|
}
|
||||||
logger.Debug("TCP stream property update",
|
logger.Debug("TCP stream property update",
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
zap.String("src", info.SrcString()),
|
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) {
|
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",
|
logger.Info("TCP stream action",
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
zap.String("src", info.SrcString()),
|
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) {
|
func (l *engineLogger) UDPStreamNew(workerID int, info ruleset.StreamInfo) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.StreamNew("udp")
|
||||||
|
}
|
||||||
logger.Debug("new UDP stream",
|
logger.Debug("new UDP stream",
|
||||||
zap.Int("workerID", workerID),
|
zap.Int("workerID", workerID),
|
||||||
zap.Int64("id", info.ID),
|
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) {
|
func (l *engineLogger) UDPStreamPropUpdate(info ruleset.StreamInfo, close bool) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.PropUpdate(toWebProps(info.Props))
|
||||||
|
}
|
||||||
logger.Debug("UDP stream property update",
|
logger.Debug("UDP stream property update",
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
zap.String("src", info.SrcString()),
|
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) {
|
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",
|
logger.Info("UDP stream action",
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
zap.String("src", info.SrcString()),
|
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) {
|
func (l *engineLogger) ModifyError(info ruleset.StreamInfo, err error) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.Error(hubInfo(info), "", err.Error())
|
||||||
|
}
|
||||||
logger.Error("modify error",
|
logger.Error("modify error",
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
zap.String("src", info.SrcString()),
|
zap.String("src", info.SrcString()),
|
||||||
@@ -408,6 +452,9 @@ func (l *engineLogger) AnalyzerErrorf(streamID int64, name string, format string
|
|||||||
type rulesetLogger struct{}
|
type rulesetLogger struct{}
|
||||||
|
|
||||||
func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
|
func (l *rulesetLogger) Log(info ruleset.StreamInfo, name string) {
|
||||||
|
if hub != nil {
|
||||||
|
hub.RuleLog(hubInfo(info), name)
|
||||||
|
}
|
||||||
logger.Info("ruleset log",
|
logger.Info("ruleset log",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.Int64("id", info.ID),
|
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) {
|
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",
|
logger.Error("ruleset match error",
|
||||||
zap.String("name", name),
|
zap.String("name", name),
|
||||||
zap.Int64("id", info.ID),
|
zap.Int64("id", info.ID),
|
||||||
@@ -431,11 +481,3 @@ func envOrDefaultString(key, def string) string {
|
|||||||
}
|
}
|
||||||
return def
|
return def
|
||||||
}
|
}
|
||||||
|
|
||||||
func envOrDefaultBool(key string, def bool) bool {
|
|
||||||
if v := os.Getenv(key); v != "" {
|
|
||||||
b, _ := strconv.ParseBool(v)
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
return def
|
|
||||||
}
|
|
||||||
|
|||||||
+320
@@ -0,0 +1,320 @@
|
|||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/apernet/OpenGFW/analyzer"
|
||||||
|
"github.com/apernet/OpenGFW/engine"
|
||||||
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
|
||||||
|
"github.com/apernet/OpenGFW/web"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hub is the (optional) statistics collector feeding the web UI. It is nil
|
||||||
|
// when the web UI is disabled.
|
||||||
|
var hub *web.Hub
|
||||||
|
|
||||||
|
// ruleManager owns the rule file and knows how to hot reload the engine.
|
||||||
|
// It is shared between the SIGHUP handler and the web UI.
|
||||||
|
type ruleManager struct {
|
||||||
|
path string
|
||||||
|
analyzers []analyzer.Analyzer
|
||||||
|
modifiers []modifier.Modifier
|
||||||
|
rsConfig *ruleset.BuiltinConfig
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
engine engine.Engine
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ web.RuleManager = (*ruleManager)(nil)
|
||||||
|
|
||||||
|
func newRuleManager(path string, ans []analyzer.Analyzer, mods []modifier.Modifier, rsConfig *ruleset.BuiltinConfig) *ruleManager {
|
||||||
|
return &ruleManager{path: path, analyzers: ans, modifiers: mods, rsConfig: rsConfig}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) SetEngine(en engine.Engine) {
|
||||||
|
m.mu.Lock()
|
||||||
|
m.engine = en
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) Path() string { return m.path }
|
||||||
|
|
||||||
|
// Compile parses and compiles a rule file content, without applying it.
|
||||||
|
func (m *ruleManager) Compile(raw string) (ruleset.Ruleset, []ruleset.ExprRule, error) {
|
||||||
|
rawRs, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse rules: %w", err)
|
||||||
|
}
|
||||||
|
rs, err := ruleset.CompileExprRules(rawRs, m.analyzers, m.modifiers, m.rsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("failed to compile rules: %w", err)
|
||||||
|
}
|
||||||
|
return rs, rawRs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload re-reads the rule file from disk and applies it to the engine.
|
||||||
|
func (m *ruleManager) Reload() error {
|
||||||
|
bs, err := os.ReadFile(m.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rs, _, err := m.Compile(string(bs))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return m.update(rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) update(rs ruleset.Ruleset) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
en := m.engine
|
||||||
|
m.mu.Unlock()
|
||||||
|
if en == nil {
|
||||||
|
return errors.New("engine is not running")
|
||||||
|
}
|
||||||
|
return en.UpdateRuleset(rs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) Load() (string, []web.Rule, error) {
|
||||||
|
bs, err := os.ReadFile(m.path)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, err
|
||||||
|
}
|
||||||
|
rules, err := ruleset.ExprRulesFromYAMLBytes(bs)
|
||||||
|
if err != nil {
|
||||||
|
// The file is still shown as-is so that the user can fix it in the editor.
|
||||||
|
return string(bs), nil, nil
|
||||||
|
}
|
||||||
|
return string(bs), toWebRules(rules), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) Validate(raw string) ([]web.Rule, error) {
|
||||||
|
_, rules, err := m.Compile(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return toWebRules(rules), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *ruleManager) Marshal(rules []web.Rule) (string, error) {
|
||||||
|
out := make([]web.Rule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Modifier != nil && r.Modifier.Name == "" {
|
||||||
|
r.Modifier = nil // Leftover from switching a rule away from `modify`
|
||||||
|
}
|
||||||
|
out = append(out, r)
|
||||||
|
}
|
||||||
|
bs, err := yaml.Marshal(out)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(bs), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply compiles the given rules, persists them to the rule file and hot
|
||||||
|
// reloads the engine. The file is only written once the rules compile.
|
||||||
|
func (m *ruleManager) Apply(raw string) ([]web.Rule, error) {
|
||||||
|
rs, rules, err := m.Compile(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := m.writeFile(raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write rule file: %w", err)
|
||||||
|
}
|
||||||
|
if err := m.update(rs); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to update ruleset: %w", err)
|
||||||
|
}
|
||||||
|
return toWebRules(rules), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFile replaces the rule file atomically so that a crash in the middle of
|
||||||
|
// a save cannot leave a truncated ruleset behind.
|
||||||
|
func (m *ruleManager) writeFile(raw string) error {
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if fi, err := os.Stat(m.path); err == nil {
|
||||||
|
mode = fi.Mode().Perm()
|
||||||
|
}
|
||||||
|
dir := filepath.Dir(m.path)
|
||||||
|
tmp, err := os.CreateTemp(dir, ".rules-*.yaml")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tmpName := tmp.Name()
|
||||||
|
defer os.Remove(tmpName) // No-op once the rename succeeded
|
||||||
|
if _, err := tmp.WriteString(raw); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Sync(); err != nil {
|
||||||
|
tmp.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.Chmod(tmpName, mode); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Rename(tmpName, m.path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toWebRules(rules []ruleset.ExprRule) []web.Rule {
|
||||||
|
out := make([]web.Rule, 0, len(rules))
|
||||||
|
for _, r := range rules {
|
||||||
|
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
|
||||||
|
if r.Modifier.Name != "" {
|
||||||
|
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
|
||||||
|
}
|
||||||
|
out = append(out, wr)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWebServer starts the web UI. It returns nil when the UI is disabled.
|
||||||
|
func startWebServer(ctx context.Context, config *cliConfig, rm *ruleManager) error {
|
||||||
|
secret := config.Web.Secret
|
||||||
|
generated := false
|
||||||
|
if secret == "" {
|
||||||
|
secret = web.RandomSecret()
|
||||||
|
generated = true
|
||||||
|
}
|
||||||
|
srv, err := web.NewServer(web.Config{
|
||||||
|
Listen: config.Web.Listen,
|
||||||
|
Secret: secret,
|
||||||
|
CertFile: config.Web.Cert,
|
||||||
|
KeyFile: config.Web.Key,
|
||||||
|
Hub: hub,
|
||||||
|
Rules: rm,
|
||||||
|
Meta: webMeta(),
|
||||||
|
Info: func() web.Info { return webInfo(config, rm) },
|
||||||
|
Geo: func() web.GeoData { return webGeoData(config) },
|
||||||
|
Logf: func(format string, args ...interface{}) {
|
||||||
|
logger.Info(fmt.Sprintf(format, args...))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return configError{Field: "web", Err: err}
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if srv.TLS() {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
fields := []zap.Field{
|
||||||
|
zap.String("listen", srv.Addr()),
|
||||||
|
zap.String("scheme", scheme),
|
||||||
|
}
|
||||||
|
if generated {
|
||||||
|
fields = append(fields, zap.String("password", secret))
|
||||||
|
logger.Warn("web UI password was not set, using a generated one", fields...)
|
||||||
|
} else {
|
||||||
|
logger.Info("web UI started", fields...)
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := srv.Run(ctx); err != nil {
|
||||||
|
logger.Error("web UI stopped", zap.Error(err))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webMeta() web.MetaInfo {
|
||||||
|
meta := web.MetaInfo{
|
||||||
|
Actions: []string{"allow", "block", "drop", "modify"},
|
||||||
|
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||||
|
}
|
||||||
|
for _, a := range analyzers {
|
||||||
|
proto := "tcp"
|
||||||
|
if _, ok := a.(analyzer.UDPAnalyzer); ok {
|
||||||
|
proto = "udp"
|
||||||
|
}
|
||||||
|
meta.Analyzers = append(meta.Analyzers, web.AnalyzerInfo{Name: a.Name(), Proto: proto})
|
||||||
|
}
|
||||||
|
for _, m := range modifiers {
|
||||||
|
meta.Modifiers = append(meta.Modifiers, m.Name())
|
||||||
|
}
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
func webInfo(config *cliConfig, rm *ruleManager) web.Info {
|
||||||
|
hostname, _ := os.Hostname()
|
||||||
|
return web.Info{
|
||||||
|
Version: appVersion,
|
||||||
|
Commit: appCommit,
|
||||||
|
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
Hostname: hostname,
|
||||||
|
RuleFile: rm.Path(),
|
||||||
|
Config: web.ConfigDigest{
|
||||||
|
IOQueueSize: config.IO.QueueSize,
|
||||||
|
IOLocal: config.IO.Local,
|
||||||
|
IORST: config.IO.RST,
|
||||||
|
Workers: config.Workers.Count,
|
||||||
|
WorkerQueue: config.Workers.QueueSize,
|
||||||
|
UDPMaxStreams: config.Workers.UDPMaxStreams,
|
||||||
|
GeoIP: config.Ruleset.GeoIp,
|
||||||
|
GeoSite: config.Ruleset.GeoSite,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// webGeoData lists what the configured geo databases contain, so that the rule
|
||||||
|
// builder can offer a picker instead of a free text field. Loading the files is
|
||||||
|
// expensive, but the web server only calls this on demand and caches the result.
|
||||||
|
func webGeoData(config *cliConfig) web.GeoData {
|
||||||
|
matcher := geo.NewGeoMatcher(config.Ruleset.GeoSite, config.Ruleset.GeoIp)
|
||||||
|
var data web.GeoData
|
||||||
|
if entries, err := matcher.ListGeoIP(); err != nil {
|
||||||
|
data.IPError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries, err := matcher.ListGeoSite(); err != nil {
|
||||||
|
data.SiteError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.Site = append(data.Site, web.GeoEntry{
|
||||||
|
Code: e.Code,
|
||||||
|
Count: e.Domains,
|
||||||
|
Attributes: e.Attributes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubInfo converts engine stream info into the shape the hub understands.
|
||||||
|
func hubInfo(info ruleset.StreamInfo) web.StreamInfo {
|
||||||
|
return web.StreamInfo{
|
||||||
|
ID: info.ID,
|
||||||
|
Proto: info.Protocol.String(),
|
||||||
|
SrcIP: info.SrcIP.String(),
|
||||||
|
SrcPort: info.SrcPort,
|
||||||
|
DstIP: info.DstIP.String(),
|
||||||
|
DstPort: info.DstPort,
|
||||||
|
Props: toWebProps(info.Props),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toWebProps(props analyzer.CombinedPropMap) web.Props {
|
||||||
|
if len(props) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(web.Props, len(props))
|
||||||
|
for name, p := range props {
|
||||||
|
out[name] = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
io:
|
||||||
|
queueSize: 1024
|
||||||
|
queueNum: 100
|
||||||
|
table: opengfw
|
||||||
|
connMarkAccept: 1001
|
||||||
|
connMarkDrop: 1002
|
||||||
|
rcvBuf: 4194304
|
||||||
|
sndBuf: 4194304
|
||||||
|
local: true
|
||||||
|
rst: false
|
||||||
|
|
||||||
|
workers:
|
||||||
|
count: 4
|
||||||
|
queueSize: 64
|
||||||
|
tcpMaxBufferedPagesTotal: 65536
|
||||||
|
tcpMaxBufferedPagesPerConn: 16
|
||||||
|
tcpTimeout: 10m
|
||||||
|
udpMaxStreams: 4096
|
||||||
|
|
||||||
|
# 指定的 geoip/geosite 档案路径
|
||||||
|
# 如果未设置,将自动从 https://github.com/Loyalsoldier/v2ray-rules-dat 下载
|
||||||
|
# ruleset:
|
||||||
|
# geoip: geoip.dat
|
||||||
|
# geosite: geosite.dat
|
||||||
|
|
||||||
|
replay:
|
||||||
|
realtime: false
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
+15
-19
@@ -15,7 +15,7 @@ var _ Engine = (*engine)(nil)
|
|||||||
|
|
||||||
type engine struct {
|
type engine struct {
|
||||||
logger Logger
|
logger Logger
|
||||||
ioList []io.PacketIO
|
io io.PacketIO
|
||||||
workers []*worker
|
workers []*worker
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ func NewEngine(config Config) (Engine, error) {
|
|||||||
}
|
}
|
||||||
return &engine{
|
return &engine{
|
||||||
logger: config.Logger,
|
logger: config.Logger,
|
||||||
ioList: config.IOs,
|
io: config.IO,
|
||||||
workers: workers,
|
workers: workers,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -58,27 +58,24 @@ func (e *engine) UpdateRuleset(r ruleset.Ruleset) error {
|
|||||||
|
|
||||||
func (e *engine) Run(ctx context.Context) error {
|
func (e *engine) Run(ctx context.Context) error {
|
||||||
ioCtx, ioCancel := context.WithCancel(ctx)
|
ioCtx, ioCancel := context.WithCancel(ctx)
|
||||||
defer ioCancel() // Stop workers & IOs
|
defer ioCancel() // Stop workers & IO
|
||||||
|
|
||||||
// Start workers
|
// Start workers
|
||||||
for _, w := range e.workers {
|
for _, w := range e.workers {
|
||||||
go w.Run(ioCtx)
|
go w.Run(ioCtx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register callbacks
|
// Register IO callback
|
||||||
errChan := make(chan error, len(e.ioList))
|
errChan := make(chan error, 1)
|
||||||
for _, i := range e.ioList {
|
err := e.io.Register(ioCtx, func(p io.Packet, err error) bool {
|
||||||
ioEntry := i // Make sure dispatch() uses the correct ioEntry
|
|
||||||
err := ioEntry.Register(ioCtx, func(p io.Packet, err error) bool {
|
|
||||||
if err != nil {
|
|
||||||
errChan <- err
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return e.dispatch(ioEntry, p)
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
errChan <- err
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
return e.dispatch(p)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block until IO errors or context is cancelled
|
// Block until IO errors or context is cancelled
|
||||||
@@ -91,8 +88,7 @@ func (e *engine) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// dispatch dispatches a packet to a worker.
|
// 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(p io.Packet) bool {
|
||||||
func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
|
||||||
data := p.Data()
|
data := p.Data()
|
||||||
ipVersion := data[0] >> 4
|
ipVersion := data[0] >> 4
|
||||||
var layerType gopacket.LayerType
|
var layerType gopacket.LayerType
|
||||||
@@ -102,7 +98,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
|||||||
layerType = layers.LayerTypeIPv6
|
layerType = layers.LayerTypeIPv6
|
||||||
} else {
|
} else {
|
||||||
// Unsupported network layer
|
// Unsupported network layer
|
||||||
_ = ioEntry.SetVerdict(p, io.VerdictAcceptStream, nil)
|
_ = e.io.SetVerdict(p, io.VerdictAcceptStream, nil)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Load balance by stream ID
|
// Load balance by stream ID
|
||||||
@@ -112,7 +108,7 @@ func (e *engine) dispatch(ioEntry io.PacketIO, p io.Packet) bool {
|
|||||||
StreamID: p.StreamID(),
|
StreamID: p.StreamID(),
|
||||||
Packet: packet,
|
Packet: packet,
|
||||||
SetVerdict: func(v io.Verdict, b []byte) error {
|
SetVerdict: func(v io.Verdict, b []byte) error {
|
||||||
return ioEntry.SetVerdict(p, v, b)
|
return e.io.SetVerdict(p, v, b)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return true
|
return true
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ type Engine interface {
|
|||||||
// Config is the configuration for the engine.
|
// Config is the configuration for the engine.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Logger Logger
|
Logger Logger
|
||||||
IOs []io.PacketIO
|
IO io.PacketIO
|
||||||
Ruleset ruleset.Ruleset
|
Ruleset ruleset.Ruleset
|
||||||
|
|
||||||
Workers int // Number of workers. Zero or negative means auto (number of CPU cores).
|
Workers int // Number of workers. Zero or negative means auto (number of CPU cores).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ go 1.21
|
|||||||
require (
|
require (
|
||||||
github.com/bwmarrin/snowflake v0.3.0
|
github.com/bwmarrin/snowflake v0.3.0
|
||||||
github.com/coreos/go-iptables v0.7.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/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf
|
||||||
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866
|
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
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/quic-go/quic-go v0.41.0
|
||||||
github.com/spf13/cobra v1.8.0
|
github.com/spf13/cobra v1.8.0
|
||||||
github.com/spf13/viper v1.18.2
|
github.com/spf13/viper v1.18.2
|
||||||
github.com/stretchr/testify v1.8.4
|
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
golang.org/x/crypto v0.19.0
|
golang.org/x/crypto v0.19.0
|
||||||
golang.org/x/sys v0.17.0
|
golang.org/x/sys v0.17.0
|
||||||
@@ -22,7 +21,6 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
||||||
github.com/google/go-cmp v0.5.9 // indirect
|
github.com/google/go-cmp v0.5.9 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||||
@@ -32,7 +30,6 @@ require (
|
|||||||
github.com/mdlayher/socket v0.1.1 // indirect
|
github.com/mdlayher/socket v0.1.1 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.1.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/locafero v0.4.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
|
|||||||
@@ -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.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 h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to=
|
||||||
github.com/expr-lang/expr v1.15.7/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
|
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 h1:NqGS3vTHzVENbIfd87cXZwdpO6MB2R1PjHMJLi4Z3ow=
|
||||||
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4=
|
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=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
|||||||
+5
-1
@@ -2,6 +2,7 @@ package io
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Verdict int
|
type Verdict int
|
||||||
@@ -29,7 +30,6 @@ type Packet interface {
|
|||||||
|
|
||||||
// PacketCallback is called for each packet received.
|
// PacketCallback is called for each packet received.
|
||||||
// Return false to "unregister" and stop receiving packets.
|
// Return false to "unregister" and stop receiving packets.
|
||||||
// It must be safe for concurrent use.
|
|
||||||
type PacketCallback func(Packet, error) bool
|
type PacketCallback func(Packet, error) bool
|
||||||
|
|
||||||
type PacketIO interface {
|
type PacketIO interface {
|
||||||
@@ -39,6 +39,10 @@ type PacketIO interface {
|
|||||||
Register(context.Context, PacketCallback) error
|
Register(context.Context, PacketCallback) error
|
||||||
// SetVerdict sets the verdict for a packet.
|
// SetVerdict sets the verdict for a packet.
|
||||||
SetVerdict(Packet, Verdict, []byte) error
|
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 closes the packet IO.
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|||||||
+127
-69
@@ -5,9 +5,11 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
"github.com/coreos/go-iptables/iptables"
|
"github.com/coreos/go-iptables/iptables"
|
||||||
"github.com/florianl/go-nfqueue"
|
"github.com/florianl/go-nfqueue"
|
||||||
@@ -27,59 +29,63 @@ const (
|
|||||||
nftTable = "opengfw"
|
nftTable = "opengfw"
|
||||||
)
|
)
|
||||||
|
|
||||||
var nftRulesForward = fmt.Sprintf(`
|
func generateNftRules(local, rst bool) (*nftTableSpec, error) {
|
||||||
define ACCEPT_CTMARK=%d
|
if local && rst {
|
||||||
define DROP_CTMARK=%d
|
return nil, errors.New("tcp rst is not supported in local mode")
|
||||||
define QUEUE_NUM=%d
|
}
|
||||||
|
table := &nftTableSpec{
|
||||||
table %s %s {
|
Family: nftFamily,
|
||||||
chain FORWARD {
|
Table: nftTable,
|
||||||
type filter hook forward priority filter; policy accept;
|
}
|
||||||
|
table.Defines = append(table.Defines, fmt.Sprintf("define ACCEPT_CTMARK=%d", nfqueueConnMarkAccept))
|
||||||
ct mark $ACCEPT_CTMARK counter accept
|
table.Defines = append(table.Defines, fmt.Sprintf("define DROP_CTMARK=%d", nfqueueConnMarkDrop))
|
||||||
ct mark $DROP_CTMARK counter drop
|
table.Defines = append(table.Defines, fmt.Sprintf("define QUEUE_NUM=%d", nfqueueNum))
|
||||||
counter queue num $QUEUE_NUM bypass
|
if local {
|
||||||
}
|
table.Chains = []nftChainSpec{
|
||||||
}
|
{Chain: "INPUT", Header: "type filter hook input priority filter; policy accept;"},
|
||||||
`, nfqueueConnMarkAccept, nfqueueConnMarkDrop, nfqueueNum, nftFamily, nftTable)
|
{Chain: "OUTPUT", Header: "type filter hook output priority filter; policy accept;"},
|
||||||
|
}
|
||||||
var nftRulesLocal = fmt.Sprintf(`
|
} else {
|
||||||
define ACCEPT_CTMARK=%d
|
table.Chains = []nftChainSpec{
|
||||||
define DROP_CTMARK=%d
|
{Chain: "FORWARD", Header: "type filter hook forward priority filter; policy accept;"},
|
||||||
define QUEUE_NUM=%d
|
}
|
||||||
|
}
|
||||||
table %s %s {
|
for i := range table.Chains {
|
||||||
chain INPUT {
|
c := &table.Chains[i]
|
||||||
type filter hook input priority filter; policy accept;
|
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")
|
||||||
ct mark $ACCEPT_CTMARK counter accept
|
if rst {
|
||||||
ct mark $DROP_CTMARK counter drop
|
c.Rules = append(c.Rules, "ip protocol tcp ct mark $DROP_CTMARK counter reject with tcp reset")
|
||||||
counter queue num $QUEUE_NUM bypass
|
}
|
||||||
}
|
c.Rules = append(c.Rules, "ct mark $DROP_CTMARK counter drop")
|
||||||
chain OUTPUT {
|
c.Rules = append(c.Rules, "counter queue num $QUEUE_NUM bypass")
|
||||||
type filter hook output priority filter; policy accept;
|
}
|
||||||
|
return table, nil
|
||||||
ct mark $ACCEPT_CTMARK counter accept
|
|
||||||
ct mark $DROP_CTMARK counter drop
|
|
||||||
counter queue num $QUEUE_NUM bypass
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`, 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"}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var iptRulesLocal = []iptRule{
|
func generateIptRules(local, rst bool) ([]iptRule, error) {
|
||||||
{"filter", "INPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkAccept), "-j", "ACCEPT"}},
|
if local && rst {
|
||||||
{"filter", "INPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}},
|
return nil, errors.New("tcp rst is not supported in local mode")
|
||||||
{"filter", "INPUT", []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}},
|
}
|
||||||
|
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"}},
|
return rules, nil
|
||||||
{"filter", "OUTPUT", []string{"-m", "connmark", "--mark", strconv.Itoa(nfqueueConnMarkDrop), "-j", "DROP"}},
|
|
||||||
{"filter", "OUTPUT", []string{"-j", "NFQUEUE", "--queue-num", strconv.Itoa(nfqueueNum), "--queue-bypass"}},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var _ PacketIO = (*nfqueuePacketIO)(nil)
|
var _ PacketIO = (*nfqueuePacketIO)(nil)
|
||||||
@@ -89,11 +95,14 @@ var errNotNFQueuePacket = errors.New("not an NFQueue packet")
|
|||||||
type nfqueuePacketIO struct {
|
type nfqueuePacketIO struct {
|
||||||
n *nfqueue.Nfqueue
|
n *nfqueue.Nfqueue
|
||||||
local bool
|
local bool
|
||||||
|
rst bool
|
||||||
rSet bool // whether the nftables/iptables rules have been set
|
rSet bool // whether the nftables/iptables rules have been set
|
||||||
|
|
||||||
// iptables not nil = use iptables instead of nftables
|
// iptables not nil = use iptables instead of nftables
|
||||||
ipt4 *iptables.IPTables
|
ipt4 *iptables.IPTables
|
||||||
ipt6 *iptables.IPTables
|
ipt6 *iptables.IPTables
|
||||||
|
|
||||||
|
protectedDialer *net.Dialer
|
||||||
}
|
}
|
||||||
|
|
||||||
type NFQueuePacketIOConfig struct {
|
type NFQueuePacketIOConfig struct {
|
||||||
@@ -101,6 +110,7 @@ type NFQueuePacketIOConfig struct {
|
|||||||
ReadBuffer int
|
ReadBuffer int
|
||||||
WriteBuffer int
|
WriteBuffer int
|
||||||
Local bool
|
Local bool
|
||||||
|
RST bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
|
func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
|
||||||
@@ -147,8 +157,21 @@ func NewNFQueuePacketIO(config NFQueuePacketIOConfig) (PacketIO, error) {
|
|||||||
return &nfqueuePacketIO{
|
return &nfqueuePacketIO{
|
||||||
n: n,
|
n: n,
|
||||||
local: config.Local,
|
local: config.Local,
|
||||||
|
rst: config.RST,
|
||||||
ipt4: ipt4,
|
ipt4: ipt4,
|
||||||
ipt6: ipt6,
|
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
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,9 +205,9 @@ func (n *nfqueuePacketIO) Register(ctx context.Context, cb PacketCallback) error
|
|||||||
}
|
}
|
||||||
if !n.rSet {
|
if !n.rSet {
|
||||||
if n.ipt4 != nil {
|
if n.ipt4 != nil {
|
||||||
err = n.setupIpt(n.local, false)
|
err = n.setupIpt(n.local, n.rst, false)
|
||||||
} else {
|
} else {
|
||||||
err = n.setupNft(n.local, false)
|
err = n.setupNft(n.local, n.rst, false)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
func (n *nfqueuePacketIO) Close() error {
|
||||||
if n.rSet {
|
if n.rSet {
|
||||||
if n.ipt4 != nil {
|
if n.ipt4 != nil {
|
||||||
_ = n.setupIpt(n.local, true)
|
_ = n.setupIpt(n.local, n.rst, true)
|
||||||
} else {
|
} else {
|
||||||
_ = n.setupNft(n.local, true)
|
_ = n.setupNft(n.local, n.rst, true)
|
||||||
}
|
}
|
||||||
n.rSet = false
|
n.rSet = false
|
||||||
}
|
}
|
||||||
return n.n.Close()
|
return n.n.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *nfqueuePacketIO) setupNft(local, remove bool) error {
|
func (n *nfqueuePacketIO) setupNft(local, rst, remove bool) error {
|
||||||
var rules string
|
rules, err := generateNftRules(local, rst)
|
||||||
if local {
|
if err != nil {
|
||||||
rules = nftRulesLocal
|
return err
|
||||||
} else {
|
|
||||||
rules = nftRulesForward
|
|
||||||
}
|
}
|
||||||
var err error
|
rulesText := rules.String()
|
||||||
if remove {
|
if remove {
|
||||||
err = nftDelete(nftFamily, nftTable)
|
err = nftDelete(nftFamily, nftTable)
|
||||||
} else {
|
} else {
|
||||||
// Delete first to make sure no leftover rules
|
// Delete first to make sure no leftover rules
|
||||||
_ = nftDelete(nftFamily, nftTable)
|
_ = nftDelete(nftFamily, nftTable)
|
||||||
err = nftAdd(rules)
|
err = nftAdd(rulesText)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -268,14 +293,11 @@ func (n *nfqueuePacketIO) setupNft(local, remove bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *nfqueuePacketIO) setupIpt(local, remove bool) error {
|
func (n *nfqueuePacketIO) setupIpt(local, rst, remove bool) error {
|
||||||
var rules []iptRule
|
rules, err := generateIptRules(local, rst)
|
||||||
if local {
|
if err != nil {
|
||||||
rules = iptRulesLocal
|
return err
|
||||||
} else {
|
|
||||||
rules = iptRulesForward
|
|
||||||
}
|
}
|
||||||
var err error
|
|
||||||
if remove {
|
if remove {
|
||||||
err = iptsBatchDeleteIfExists([]*iptables.IPTables{n.ipt4, n.ipt6}, rules)
|
err = iptsBatchDeleteIfExists([]*iptables.IPTables{n.ipt4, n.ipt6}, rules)
|
||||||
} else {
|
} else {
|
||||||
@@ -330,6 +352,42 @@ func nftDelete(family, table string) error {
|
|||||||
return cmd.Run()
|
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 {
|
type iptRule struct {
|
||||||
Table, Chain string
|
Table, Chain string
|
||||||
RuleSpec []string
|
RuleSpec []string
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ func (l *V2GeoLoader) shouldDownload(filename string) bool {
|
|||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
dt := time.Now().Sub(info.ModTime())
|
dt := time.Since(info.ModTime())
|
||||||
if l.UpdateInterval == 0 {
|
if l.UpdateInterval == 0 {
|
||||||
return dt > geoDefaultUpdateInterval
|
return dt > geoDefaultUpdateInterval
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package geo
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
@@ -14,14 +15,12 @@ type GeoMatcher struct {
|
|||||||
ipMatcherLock sync.Mutex
|
ipMatcherLock sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) (*GeoMatcher, error) {
|
func NewGeoMatcher(geoSiteFilename, geoIpFilename string) *GeoMatcher {
|
||||||
geoLoader := NewDefaultGeoLoader(geoSiteFilename, geoIpFilename)
|
|
||||||
|
|
||||||
return &GeoMatcher{
|
return &GeoMatcher{
|
||||||
geoLoader: geoLoader,
|
geoLoader: NewDefaultGeoLoader(geoSiteFilename, geoIpFilename),
|
||||||
geoSiteMatcher: make(map[string]hostMatcher),
|
geoSiteMatcher: make(map[string]hostMatcher),
|
||||||
geoIpMatcher: make(map[string]hostMatcher),
|
geoIpMatcher: make(map[string]hostMatcher),
|
||||||
}, nil
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *GeoMatcher) MatchGeoIp(ip, condition string) bool {
|
func (g *GeoMatcher) MatchGeoIp(ip, condition string) bool {
|
||||||
@@ -99,6 +98,69 @@ func (g *GeoMatcher) LoadGeoSite() error {
|
|||||||
return err
|
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 {
|
func (g *GeoMatcher) LoadGeoIP() error {
|
||||||
_, err := g.geoLoader.LoadGeoIP()
|
_, err := g.geoLoader.LoadGeoIP()
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+126
-69
@@ -1,11 +1,15 @@
|
|||||||
package ruleset
|
package ruleset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/expr-lang/expr/builtin"
|
||||||
|
|
||||||
"github.com/expr-lang/expr"
|
"github.com/expr-lang/expr"
|
||||||
"github.com/expr-lang/expr/ast"
|
"github.com/expr-lang/expr/ast"
|
||||||
@@ -38,8 +42,13 @@ func ExprRulesFromYAML(file string) ([]ExprRule, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
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
|
var rules []ExprRule
|
||||||
err = yaml.Unmarshal(bs, &rules)
|
err := yaml.Unmarshal(bs, &rules)
|
||||||
return rules, err
|
return rules, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -55,10 +64,9 @@ type compiledExprRule struct {
|
|||||||
var _ Ruleset = (*exprRuleset)(nil)
|
var _ Ruleset = (*exprRuleset)(nil)
|
||||||
|
|
||||||
type exprRuleset struct {
|
type exprRuleset struct {
|
||||||
Rules []compiledExprRule
|
Rules []compiledExprRule
|
||||||
Ans []analyzer.Analyzer
|
Ans []analyzer.Analyzer
|
||||||
Logger Logger
|
Logger Logger
|
||||||
GeoMatcher *geo.GeoMatcher
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *exprRuleset) Analyzers(info StreamInfo) []analyzer.Analyzer {
|
func (r *exprRuleset) Analyzers(info StreamInfo) []analyzer.Analyzer {
|
||||||
@@ -100,10 +108,7 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
|||||||
fullAnMap := analyzersToMap(ans)
|
fullAnMap := analyzersToMap(ans)
|
||||||
fullModMap := modifiersToMap(mods)
|
fullModMap := modifiersToMap(mods)
|
||||||
depAnMap := make(map[string]analyzer.Analyzer)
|
depAnMap := make(map[string]analyzer.Analyzer)
|
||||||
geoMatcher, err := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
|
funcMap := buildFunctionMap(config)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
// Compile all rules and build a map of analyzers that are used by the rules.
|
// Compile all rules and build a map of analyzers that are used by the rules.
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
if rule.Action == "" && !rule.Log {
|
if rule.Action == "" && !rule.Log {
|
||||||
@@ -118,13 +123,19 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
|||||||
action = &a
|
action = &a
|
||||||
}
|
}
|
||||||
visitor := &idVisitor{Variables: make(map[string]bool), Identifiers: make(map[string]bool)}
|
visitor := &idVisitor{Variables: make(map[string]bool), Identifiers: make(map[string]bool)}
|
||||||
patcher := &idPatcher{}
|
patcher := &idPatcher{FuncMap: funcMap}
|
||||||
program, err := expr.Compile(rule.Expr,
|
program, err := expr.Compile(rule.Expr,
|
||||||
func(c *conf.Config) {
|
func(c *conf.Config) {
|
||||||
c.Strict = false
|
c.Strict = false
|
||||||
c.Expect = reflect.Bool
|
c.Expect = reflect.Bool
|
||||||
c.Visitors = append(c.Visitors, visitor, patcher)
|
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 {
|
if err != nil {
|
||||||
@@ -138,24 +149,15 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
|||||||
if isBuiltInAnalyzer(name) || visitor.Variables[name] {
|
if isBuiltInAnalyzer(name) || visitor.Variables[name] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Check if it's one of the built-in functions, and if so,
|
if f, ok := funcMap[name]; ok {
|
||||||
// skip it as an analyzer & do initialization if necessary.
|
// Built-in function, initialize if necessary
|
||||||
switch name {
|
if f.InitFunc != nil {
|
||||||
case "geoip":
|
if err := f.InitFunc(); err != nil {
|
||||||
if err := geoMatcher.LoadGeoIP(); err != nil {
|
return nil, fmt.Errorf("rule %q failed to initialize function %q: %w", rule.Name, name, err)
|
||||||
return nil, fmt.Errorf("rule %q failed to load geoip: %w", rule.Name, err)
|
}
|
||||||
}
|
|
||||||
case "geosite":
|
|
||||||
if err := geoMatcher.LoadGeoSite(); err != nil {
|
|
||||||
return nil, fmt.Errorf("rule %q failed to load geosite: %w", rule.Name, err)
|
|
||||||
}
|
|
||||||
case "cidr":
|
|
||||||
// No initialization needed for CIDR.
|
|
||||||
default:
|
|
||||||
a, ok := fullAnMap[name]
|
|
||||||
if !ok {
|
|
||||||
return nil, fmt.Errorf("rule %q uses unknown analyzer %q", rule.Name, name)
|
|
||||||
}
|
}
|
||||||
|
} else if a, ok := fullAnMap[name]; ok {
|
||||||
|
// Analyzer, add to dependency map
|
||||||
depAnMap[name] = a
|
depAnMap[name] = a
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -184,37 +186,12 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier
|
|||||||
depAns = append(depAns, a)
|
depAns = append(depAns, a)
|
||||||
}
|
}
|
||||||
return &exprRuleset{
|
return &exprRuleset{
|
||||||
Rules: compiledRules,
|
Rules: compiledRules,
|
||||||
Ans: depAns,
|
Ans: depAns,
|
||||||
Logger: config.Logger,
|
Logger: config.Logger,
|
||||||
GeoMatcher: geoMatcher,
|
|
||||||
}, nil
|
}, 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{} {
|
func streamInfoToExprEnv(info StreamInfo) map[string]interface{} {
|
||||||
m := map[string]interface{}{
|
m := map[string]interface{}{
|
||||||
"id": info.ID,
|
"id": info.ID,
|
||||||
@@ -299,29 +276,109 @@ func (v *idVisitor) Visit(node *ast.Node) {
|
|||||||
// idPatcher patches the AST during expr compilation, replacing certain values with
|
// idPatcher patches the AST during expr compilation, replacing certain values with
|
||||||
// their internal representations for better runtime performance.
|
// their internal representations for better runtime performance.
|
||||||
type idPatcher struct {
|
type idPatcher struct {
|
||||||
Err error
|
FuncMap map[string]*Function
|
||||||
|
Err error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *idPatcher) Visit(node *ast.Node) {
|
func (p *idPatcher) Visit(node *ast.Node) {
|
||||||
switch (*node).(type) {
|
switch (*node).(type) {
|
||||||
case *ast.CallNode:
|
case *ast.CallNode:
|
||||||
callNode := (*node).(*ast.CallNode)
|
callNode := (*node).(*ast.CallNode)
|
||||||
if callNode.Func == nil {
|
if callNode.Callee == nil {
|
||||||
// Ignore invalid call nodes
|
// Ignore invalid call nodes
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
switch callNode.Func.Name {
|
if f, ok := p.FuncMap[callNode.Callee.String()]; ok {
|
||||||
case "cidr":
|
if f.PatchFunc != nil {
|
||||||
cidrStringNode, ok := callNode.Arguments[1].(*ast.StringNode)
|
if err := f.PatchFunc(&callNode.Arguments); err != nil {
|
||||||
if !ok {
|
p.Err = err
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
|
|
||||||
if err != nil {
|
|
||||||
p.Err = err
|
|
||||||
return
|
|
||||||
}
|
|
||||||
callNode.Arguments[1] = &ast.ConstantNode{Value: cidr}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Function struct {
|
||||||
|
InitFunc func() error
|
||||||
|
PatchFunc func(args *[]ast.Node) error
|
||||||
|
Func func(params ...any) (any, error)
|
||||||
|
Types []reflect.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildFunctionMap(config *BuiltinConfig) map[string]*Function {
|
||||||
|
geoMatcher := geo.NewGeoMatcher(config.GeoSiteFilename, config.GeoIpFilename)
|
||||||
|
return map[string]*Function{
|
||||||
|
"geoip": {
|
||||||
|
InitFunc: geoMatcher.LoadGeoIP,
|
||||||
|
PatchFunc: nil,
|
||||||
|
Func: func(params ...any) (any, error) {
|
||||||
|
return geoMatcher.MatchGeoIp(params[0].(string), params[1].(string)), nil
|
||||||
|
},
|
||||||
|
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoIp)},
|
||||||
|
},
|
||||||
|
"geosite": {
|
||||||
|
InitFunc: geoMatcher.LoadGeoSite,
|
||||||
|
PatchFunc: nil,
|
||||||
|
Func: func(params ...any) (any, error) {
|
||||||
|
return geoMatcher.MatchGeoSite(params[0].(string), params[1].(string)), nil
|
||||||
|
},
|
||||||
|
Types: []reflect.Type{reflect.TypeOf(geoMatcher.MatchGeoSite)},
|
||||||
|
},
|
||||||
|
"cidr": {
|
||||||
|
InitFunc: nil,
|
||||||
|
PatchFunc: func(args *[]ast.Node) error {
|
||||||
|
cidrStringNode, ok := (*args)[1].(*ast.StringNode)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("cidr: invalid argument type")
|
||||||
|
}
|
||||||
|
cidr, err := builtins.CompileCIDR(cidrStringNode.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
(*args)[1] = &ast.ConstantNode{Value: cidr}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Func: func(params ...any) (any, error) {
|
||||||
|
return builtins.MatchCIDR(params[0].(string), params[1].(*net.IPNet)), nil
|
||||||
|
},
|
||||||
|
Types: []reflect.Type{reflect.TypeOf(builtins.MatchCIDR)},
|
||||||
|
},
|
||||||
|
"lookup": {
|
||||||
|
InitFunc: nil,
|
||||||
|
PatchFunc: func(args *[]ast.Node) error {
|
||||||
|
var serverStr *ast.StringNode
|
||||||
|
if len(*args) > 1 {
|
||||||
|
// Has the optional server argument
|
||||||
|
var ok bool
|
||||||
|
serverStr, ok = (*args)[1].(*ast.StringNode)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("lookup: invalid argument type")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := &net.Resolver{
|
||||||
|
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
if serverStr != nil {
|
||||||
|
address = serverStr.Value
|
||||||
|
}
|
||||||
|
return config.ProtectedDialContext(ctx, network, address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if len(*args) > 1 {
|
||||||
|
(*args)[1] = &ast.ConstantNode{Value: r}
|
||||||
|
} else {
|
||||||
|
*args = append(*args, &ast.ConstantNode{Value: r})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Func: func(params ...any) (any, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return params[1].(*net.Resolver).LookupHost(ctx, params[0].(string))
|
||||||
|
},
|
||||||
|
Types: []reflect.Type{
|
||||||
|
reflect.TypeOf((func(string, *net.Resolver) []string)(nil)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package ruleset
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/apernet/OpenGFW/analyzer"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/tcp"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/udp"
|
||||||
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
|
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// builderExpressions are the canonical expressions produced by the visual rule
|
||||||
|
// builder of the web UI (see web/frontend/src/lib/rule/compile.ts). They are
|
||||||
|
// pinned here so that a change to the expression language, the analyzers or the
|
||||||
|
// built-in functions cannot silently break the builder.
|
||||||
|
var builderExpressions = []string{
|
||||||
|
// Domain or subdomain, single and multiple values
|
||||||
|
`(string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com")`,
|
||||||
|
`((string(tls?.req?.sni) == "example.com" || string(tls?.req?.sni) endsWith ".example.com") || (string(tls?.req?.sni) == "evil.test" || string(tls?.req?.sni) endsWith ".evil.test")) && proto == "tcp"`,
|
||||||
|
// CIDR and GeoIP, including negation
|
||||||
|
`(cidr(ip.dst, "10.0.0.0/8") || cidr(ip.dst, "fd00::/8")) || !(geoip(ip.dst, "cn") || geoip(ip.dst, "hk"))`,
|
||||||
|
// Port equality and ranges
|
||||||
|
`(port.dst >= 1000 && port.dst <= 2000) && (port.src == 80 || port.src == 443)`,
|
||||||
|
// Wildcards over DNS questions
|
||||||
|
`any(dns?.questions ?? [], {(string(.name) endsWith ".ads.com" || string(.name) startsWith "x.")})`,
|
||||||
|
// GeoSite over DNS questions
|
||||||
|
`any(dns?.questions ?? [], {geosite(string(.name), "category-ads-all")})`,
|
||||||
|
// HTTP fields: negation, regular expressions and header lookups
|
||||||
|
`!(string(http?.req?.headers?.host) contains "tracker") && string(http?.req?.path) matches "^/api/v\\d+/" && string(get(http?.req?.headers, "user-agent")) startsWith "curl"`,
|
||||||
|
// Protocol detection
|
||||||
|
`(ssh != nil || trojan != nil)`,
|
||||||
|
// Wildcard edge cases: "any value" and a star in the middle
|
||||||
|
`string(quic?.req?.sni) != "" && string(tls?.req?.sni) matches "^www\\..*\\.com$" && ip.src == "1.2.3.4" && string(http?.req?.method) == "POST"`,
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompileBuilderExpressions(t *testing.T) {
|
||||||
|
analyzers := []analyzer.Analyzer{
|
||||||
|
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
|
||||||
|
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
|
||||||
|
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
|
||||||
|
}
|
||||||
|
modifiers := []modifier.Modifier{&modUDP.DNSModifier{}}
|
||||||
|
config := &BuiltinConfig{
|
||||||
|
Logger: nopLogger{},
|
||||||
|
// Point at a file that does not exist: expressions still have to
|
||||||
|
// compile, only loading the database is expected to fail.
|
||||||
|
GeoSiteFilename: "testdata/missing-geosite.dat",
|
||||||
|
GeoIpFilename: "testdata/missing-geoip.dat",
|
||||||
|
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, expr := range builderExpressions {
|
||||||
|
rules := []ExprRule{{Name: "test", Action: "block", Expr: expr}}
|
||||||
|
_, err := CompileExprRules(rules, analyzers, modifiers, config)
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// geoip()/geosite() need a database, which this test does not ship.
|
||||||
|
// Reaching the initialization step means the expression itself is fine.
|
||||||
|
if strings.Contains(err.Error(), "failed to initialize function") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t.Errorf("expression failed to compile: %s\n %v", expr, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopLogger struct{}
|
||||||
|
|
||||||
|
func (nopLogger) Log(StreamInfo, string) {}
|
||||||
|
func (nopLogger) MatchError(StreamInfo, string, error) {}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package ruleset
|
package ruleset
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -100,7 +101,8 @@ type Logger interface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type BuiltinConfig struct {
|
type BuiltinConfig struct {
|
||||||
Logger Logger
|
Logger Logger
|
||||||
GeoSiteFilename string
|
GeoSiteFilename string
|
||||||
GeoIpFilename string
|
GeoIpFilename string
|
||||||
|
ProtectedDialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
}
|
}
|
||||||
|
|||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
# OpenGFW Web UI
|
||||||
|
|
||||||
|
A Vue 3 + Tailwind CSS dashboard for OpenGFW, built with [shadcn/ui](https://ui.shadcn.com)
|
||||||
|
style components on top of [Reka UI](https://reka-ui.com) primitives. It is embedded into
|
||||||
|
the OpenGFW binary and served by the `web` package.
|
||||||
|
|
||||||
|
- **Dashboard** — live counters, traffic chart, verdict/protocol split, top hosts,
|
||||||
|
blocked destinations and triggered rules
|
||||||
|
- **Events** — real-time feed of verdicts, rule logs and errors with filters and a
|
||||||
|
detail view showing raw analyzer properties
|
||||||
|
- **Rules** — a visual condition builder, a raw expression editor and a YAML editor,
|
||||||
|
all validated by the engine itself; saving writes the rule file and hot reloads the
|
||||||
|
running engine
|
||||||
|
- **Analyzers** — which analyzers are compiled in and how much traffic each one saw
|
||||||
|
- **Settings** — theme (light/dark/system), language (English/中文) and instance info
|
||||||
|
|
||||||
|
The layout is responsive: a sidebar on desktop, a bottom tab bar and bottom-sheet
|
||||||
|
dialogs on phones.
|
||||||
|
|
||||||
|
## Enabling it
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# config.yaml
|
||||||
|
web:
|
||||||
|
enabled: true
|
||||||
|
listen: :8080
|
||||||
|
secret: your-password-here
|
||||||
|
# cert: /path/to/fullchain.pem
|
||||||
|
# key: /path/to/privkey.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
If `secret` is empty a random password is generated and printed to the log on startup.
|
||||||
|
|
||||||
|
## Rule builder
|
||||||
|
|
||||||
|
Rules are still plain expr expressions in the rule file; the builder is only a way to
|
||||||
|
write them without memorising the syntax. Conditions are rows of *field + operator +
|
||||||
|
values*, joined with AND or OR, each row negatable:
|
||||||
|
|
||||||
|
| Field group | Fields |
|
||||||
|
| ----------- | --------------------------------------------------------------- |
|
||||||
|
| Connection | transport protocol, source/destination IP, source/destination port |
|
||||||
|
| Domain | TLS SNI, QUIC SNI, DNS query name |
|
||||||
|
| HTTP | Host, path, method, User-Agent |
|
||||||
|
| Protocol | detected protocol (any analyzer) |
|
||||||
|
|
||||||
|
Operators cover the things rules usually need:
|
||||||
|
|
||||||
|
| Operator | Generated expression |
|
||||||
|
| ----------------------- | ---------------------------------------------------------- |
|
||||||
|
| domain or subdomain of | `(S == "x.com" \|\| S endsWith ".x.com")` |
|
||||||
|
| matches wildcard | `*.x.com` → `endsWith`, `x.*` → `startsWith`, `*ad*` → `contains`, `a.*.c` → `matches` |
|
||||||
|
| in CIDR | `cidr(ip.dst, "10.0.0.0/8")`, validated as you type |
|
||||||
|
| in GeoIP country | `geoip(ip.dst, "cn")`, picked from the loaded database |
|
||||||
|
| in GeoSite category | `geosite(string(.name), "category-ads-all@cn")` |
|
||||||
|
| in range | `(port.dst >= 1000 && port.dst <= 2000)` |
|
||||||
|
| is / contains / starts / ends / regex | the matching expr operator |
|
||||||
|
|
||||||
|
Multiple values in one row are OR-ed together, so one row can hold a whole domain or
|
||||||
|
country list. The generated expression is shown live and validated by the engine before
|
||||||
|
the rule is accepted.
|
||||||
|
|
||||||
|
Opening an existing rule parses its expression back into conditions. Anything the
|
||||||
|
builder cannot represent — hand written expressions, functions like `lookup()` — opens
|
||||||
|
in the expression editor with a warning instead of being rewritten.
|
||||||
|
|
||||||
|
The GeoIP picker lists whatever the configured `geoip.dat` contains: country codes plus,
|
||||||
|
with the default Loyalsoldier database, provider groups such as `cloudflare`, `google`
|
||||||
|
and `telegram`. Matching by AS number is not something the v2geo data format supports,
|
||||||
|
so use those groups or an explicit CIDR list instead.
|
||||||
|
|
||||||
|
`web/frontend/src/lib/rule/` holds the whole thing: `fields.ts` (catalog), `compile.ts`
|
||||||
|
(builder → expr), `parse.ts` (expr → builder) and `validate.ts`. The canonical
|
||||||
|
expressions are pinned in `ruleset/expr_test.go`, which compiles them with the real
|
||||||
|
engine.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├── api.go HTTP handlers (JSON API + SSE)
|
||||||
|
├── auth.go password login, session tokens
|
||||||
|
├── hub.go statistics collection and the live event fan-out
|
||||||
|
├── server.go routes, static file serving, public types
|
||||||
|
├── embed.go //go:embed of dist
|
||||||
|
├── devserver/ standalone server with synthetic data (any OS)
|
||||||
|
├── dist/ built UI, embedded into the binary (committed)
|
||||||
|
└── frontend/ Vue sources
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
The engine only builds on Linux, so for UI work there is a standalone server that
|
||||||
|
feeds the UI synthetic traffic and an in-memory ruleset:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go run ./web/devserver # http://127.0.0.1:8080, password: opengfw
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, in another terminal, run Vite with hot reload (it proxies `/api` to `:8080`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/frontend
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
To produce the embedded build (this is what `make web` runs):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd web/frontend && npm run build # writes ../dist
|
||||||
|
```
|
||||||
|
|
||||||
|
`web/dist` is committed so that `go build` works without Node installed. Rebuild it
|
||||||
|
whenever you change the frontend.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints live under `/api/v1` and return JSON. Except for `login`, every request
|
||||||
|
must carry `Authorization: Bearer <token>`; `GET` endpoints also accept the session
|
||||||
|
cookie set at login, which is what the `EventSource` connection uses. Mutating
|
||||||
|
endpoints only accept the bearer token, which makes them immune to CSRF.
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
| ---------- | -------------------- | -------------------------------------------------- |
|
||||||
|
| `POST` | `/login` | exchange the password for a session token |
|
||||||
|
| `POST` | `/logout` | invalidate the current session |
|
||||||
|
| `GET` | `/info` | version, platform and engine configuration |
|
||||||
|
| `GET` | `/meta` | available analyzers, modifiers, actions, functions |
|
||||||
|
| `GET` | `/geo` | GeoIP/GeoSite entries for the rule builder pickers |
|
||||||
|
| `GET` | `/metrics` | counters, time series and top N lists |
|
||||||
|
| `GET` | `/events?limit=` | recent events from the ring buffer |
|
||||||
|
| `GET` | `/live` | server-sent events: `event` and `metrics` frames |
|
||||||
|
| `GET/PUT` | `/rules` | read / replace the ruleset (`raw` YAML or `rules`) |
|
||||||
|
| `POST` | `/rules/validate` | compile without applying; also converts YAML ⇄ rules |
|
||||||
+317
@@ -0,0 +1,317 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type apiError struct {
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeError(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, apiError{Error: msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func methodAllowed(w http.ResponseWriter, r *http.Request, methods ...string) bool {
|
||||||
|
for _, m := range methods {
|
||||||
|
if r.Method == m {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeBody(w http.ResponseWriter, r *http.Request, v interface{}) bool {
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, 4<<20) // 4 MiB is plenty for a rule file
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid request body: "+err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/login
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodPost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token, expiry, err := s.auth.login(r.RemoteAddr, req.Password)
|
||||||
|
if err != nil {
|
||||||
|
code := http.StatusUnauthorized
|
||||||
|
if errors.Is(err, errTooManyAttempts) {
|
||||||
|
code = http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
writeError(w, code, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: token,
|
||||||
|
Path: "/",
|
||||||
|
Expires: expiry,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.TLS(),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"token": token,
|
||||||
|
"expiresAt": expiry.UnixMilli(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/logout
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodPost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.auth.logout(bearerToken(r))
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: sessionCookieName,
|
||||||
|
Value: "",
|
||||||
|
Path: "/",
|
||||||
|
MaxAge: -1,
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: s.TLS(),
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
})
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/info
|
||||||
|
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var info Info
|
||||||
|
if s.config.Info != nil {
|
||||||
|
info = s.config.Info()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/meta
|
||||||
|
func (s *Server) handleMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.config.Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/metrics
|
||||||
|
func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.config.Hub.Metrics())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/events?limit=200
|
||||||
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
limit := 200
|
||||||
|
if v := r.URL.Query().Get("limit"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
limit = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"events": s.config.Hub.Events(limit),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/live - server-sent events carrying live events and metrics.
|
||||||
|
func (s *Server) handleLive(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeError(w, http.StatusInternalServerError, "streaming unsupported")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h := w.Header()
|
||||||
|
h.Set("Content-Type", "text/event-stream")
|
||||||
|
h.Set("Cache-Control", "no-cache")
|
||||||
|
h.Set("Connection", "keep-alive")
|
||||||
|
h.Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
events, unsubscribe := s.config.Hub.Subscribe()
|
||||||
|
defer unsubscribe()
|
||||||
|
|
||||||
|
metricsTicker := time.NewTicker(2 * time.Second)
|
||||||
|
defer metricsTicker.Stop()
|
||||||
|
keepAlive := time.NewTicker(20 * time.Second)
|
||||||
|
defer keepAlive.Stop()
|
||||||
|
|
||||||
|
send := func(event string, v interface{}) bool {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, data); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !send("metrics", s.config.Hub.Metrics()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case ev, ok := <-events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !send("event", ev) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-metricsTicker.C:
|
||||||
|
if !send("metrics", s.config.Hub.Metrics()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-keepAlive.C:
|
||||||
|
if _, err := fmt.Fprint(w, ": ping\n\n"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/v1/geo - the entries of the configured geo databases.
|
||||||
|
func (s *Server) handleGeo(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config.Geo == nil {
|
||||||
|
writeJSON(w, http.StatusOK, GeoData{
|
||||||
|
IPError: "geo databases are unavailable",
|
||||||
|
SiteError: "geo databases are unavailable",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.geoMu.Lock()
|
||||||
|
defer s.geoMu.Unlock()
|
||||||
|
fresh := s.geoCache != nil && time.Since(s.geoLoaded) < geoCacheTTL
|
||||||
|
if !fresh || r.URL.Query().Get("reload") == "1" {
|
||||||
|
data := s.config.Geo()
|
||||||
|
s.geoCache = &data
|
||||||
|
s.geoLoaded = time.Now()
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, s.geoCache)
|
||||||
|
}
|
||||||
|
|
||||||
|
type rulesRequest struct {
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Rules []Rule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type rulesResponse struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Raw string `json:"raw"`
|
||||||
|
Rules []Rule `json:"rules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRaw turns a request into rule file content.
|
||||||
|
func (s *Server) resolveRaw(req rulesRequest) (string, error) {
|
||||||
|
if req.Rules != nil {
|
||||||
|
return s.config.Rules.Marshal(req.Rules)
|
||||||
|
}
|
||||||
|
return req.Raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET/PUT /api/v1/rules
|
||||||
|
func (s *Server) handleRules(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodGet, http.MethodPut) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config.Rules == nil {
|
||||||
|
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method == http.MethodGet {
|
||||||
|
raw, rules, err := s.config.Rules.Load()
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req rulesRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := s.resolveRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules, err := s.config.Rules.Apply(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.config.Logf("ruleset updated from web UI (%d rules)", len(rules))
|
||||||
|
writeJSON(w, http.StatusOK, rulesResponse{Path: s.config.Rules.Path(), Raw: raw, Rules: rules})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/v1/rules/validate
|
||||||
|
func (s *Server) handleRulesValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !methodAllowed(w, r, http.MethodPost) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.config.Rules == nil {
|
||||||
|
writeError(w, http.StatusNotImplemented, "rule management is unavailable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req rulesRequest
|
||||||
|
if !decodeBody(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, err := s.resolveRaw(req)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{"valid": false, "error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rules, err := s.config.Rules.Validate(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"valid": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
"raw": raw,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"valid": true,
|
||||||
|
"rules": rules,
|
||||||
|
"raw": raw,
|
||||||
|
})
|
||||||
|
}
|
||||||
+152
@@ -0,0 +1,152 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sessionCookieName = "opengfw_session"
|
||||||
|
sessionTTL = 7 * 24 * time.Hour
|
||||||
|
maxLoginFailures = 8
|
||||||
|
loginBanDuration = 5 * time.Minute
|
||||||
|
)
|
||||||
|
|
||||||
|
type authenticator struct {
|
||||||
|
secret string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]time.Time // token -> expiry
|
||||||
|
failures map[string]*failureRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
type failureRecord struct {
|
||||||
|
count int
|
||||||
|
until time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthenticator(secret string) *authenticator {
|
||||||
|
return &authenticator{
|
||||||
|
secret: secret,
|
||||||
|
sessions: make(map[string]time.Time),
|
||||||
|
failures: make(map[string]*failureRecord),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RandomSecret generates a secret to be used when the user did not set one.
|
||||||
|
func RandomSecret() string {
|
||||||
|
b := make([]byte, 12)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return "opengfw"
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newToken() string {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// login verifies the password and returns a new session token.
|
||||||
|
func (a *authenticator) login(remoteAddr, password string) (string, time.Time, error) {
|
||||||
|
ip := hostOnly(remoteAddr)
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
now := time.Now()
|
||||||
|
if rec, ok := a.failures[ip]; ok && rec.count >= maxLoginFailures && now.Before(rec.until) {
|
||||||
|
return "", time.Time{}, errTooManyAttempts
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(password), []byte(a.secret)) != 1 {
|
||||||
|
rec, ok := a.failures[ip]
|
||||||
|
if !ok || now.After(rec.until) {
|
||||||
|
rec = &failureRecord{}
|
||||||
|
a.failures[ip] = rec
|
||||||
|
}
|
||||||
|
rec.count++
|
||||||
|
rec.until = now.Add(loginBanDuration)
|
||||||
|
return "", time.Time{}, errBadCredentials
|
||||||
|
}
|
||||||
|
delete(a.failures, ip)
|
||||||
|
token := newToken()
|
||||||
|
if token == "" {
|
||||||
|
return "", time.Time{}, errInternal
|
||||||
|
}
|
||||||
|
expiry := now.Add(sessionTTL)
|
||||||
|
a.sessions[token] = expiry
|
||||||
|
a.gcLocked(now)
|
||||||
|
return token, expiry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *authenticator) logout(token string) {
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
delete(a.sessions, token)
|
||||||
|
a.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *authenticator) valid(token string) bool {
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
expiry, ok := a.sessions[token]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(expiry) {
|
||||||
|
delete(a.sessions, token)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *authenticator) gcLocked(now time.Time) {
|
||||||
|
for t, exp := range a.sessions {
|
||||||
|
if now.After(exp) {
|
||||||
|
delete(a.sessions, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ip, rec := range a.failures {
|
||||||
|
if now.After(rec.until) {
|
||||||
|
delete(a.failures, ip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken returns the token carried by the Authorization header, if any.
|
||||||
|
func bearerToken(r *http.Request) string {
|
||||||
|
const prefix = "Bearer "
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
if len(h) > len(prefix) && h[:len(prefix)] == prefix {
|
||||||
|
return h[len(prefix):]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// cookieToken returns the token carried by the session cookie, if any.
|
||||||
|
func cookieToken(r *http.Request) string {
|
||||||
|
c, err := r.Cookie(sessionCookieName)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostOnly(addr string) string {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return addr
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Command devserver runs the OpenGFW web UI against synthetic data.
|
||||||
|
//
|
||||||
|
// The engine itself only builds on Linux (it needs NFQueue), so this little
|
||||||
|
// program exists to let the frontend be developed and reviewed anywhere:
|
||||||
|
//
|
||||||
|
// go run ./web/devserver
|
||||||
|
//
|
||||||
|
// It serves the embedded UI on :8080 with the password "opengfw".
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/apernet/OpenGFW/analyzer"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/tcp"
|
||||||
|
"github.com/apernet/OpenGFW/analyzer/udp"
|
||||||
|
"github.com/apernet/OpenGFW/modifier"
|
||||||
|
modUDP "github.com/apernet/OpenGFW/modifier/udp"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset"
|
||||||
|
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
|
||||||
|
"github.com/apernet/OpenGFW/web"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
const password = "opengfw"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
hub := web.NewHub()
|
||||||
|
rm := &memoryRules{}
|
||||||
|
if err := rm.init(); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := web.NewServer(web.Config{
|
||||||
|
Listen: ":8080",
|
||||||
|
Secret: password,
|
||||||
|
Hub: hub,
|
||||||
|
Rules: rm,
|
||||||
|
Meta: web.MetaInfo{
|
||||||
|
Analyzers: []web.AnalyzerInfo{
|
||||||
|
{Name: "http", Proto: "tcp"}, {Name: "tls", Proto: "tcp"},
|
||||||
|
{Name: "ssh", Proto: "tcp"}, {Name: "socks", Proto: "tcp"},
|
||||||
|
{Name: "trojan", Proto: "tcp"}, {Name: "fet", Proto: "tcp"},
|
||||||
|
{Name: "dns", Proto: "udp"}, {Name: "quic", Proto: "udp"},
|
||||||
|
{Name: "openvpn", Proto: "udp"}, {Name: "wireguard", Proto: "udp"},
|
||||||
|
},
|
||||||
|
Modifiers: []string{"dns"},
|
||||||
|
Actions: []string{"allow", "block", "drop", "modify"},
|
||||||
|
Functions: []string{"geoip", "geosite", "cidr", "lookup"},
|
||||||
|
},
|
||||||
|
Info: func() web.Info {
|
||||||
|
host, _ := os.Hostname()
|
||||||
|
return web.Info{
|
||||||
|
Version: "devserver",
|
||||||
|
Platform: runtime.GOOS + "/" + runtime.GOARCH,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
Hostname: host,
|
||||||
|
RuleFile: "rules.yaml (in memory)",
|
||||||
|
Config: web.ConfigDigest{
|
||||||
|
IOQueueSize: 1024, IORST: true, Workers: 4,
|
||||||
|
WorkerQueue: 64, UDPMaxStreams: 4096,
|
||||||
|
GeoIP: "geoip.dat", GeoSite: "geosite.dat",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Geo: geoData,
|
||||||
|
Logf: log.Printf,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
ch := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||||
|
<-ch
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
hub.WorkerStarted()
|
||||||
|
}
|
||||||
|
go generate(ctx, hub)
|
||||||
|
|
||||||
|
log.Printf("web UI on http://127.0.0.1:8080 (password: %s)", password)
|
||||||
|
if err := srv.Run(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
hosts = []string{
|
||||||
|
"www.google.com", "github.com", "cdn.jsdelivr.net", "telegram.org",
|
||||||
|
"ads.example.net", "tracker.evil.test", "api.openai.com", "www.wikipedia.org",
|
||||||
|
"registry.npmjs.org", "malware.bad.test",
|
||||||
|
}
|
||||||
|
ips = []string{"1.1.1.1", "8.8.8.8", "93.184.216.34", "104.16.132.229", "2606:4700::6810:84e5"}
|
||||||
|
)
|
||||||
|
|
||||||
|
// generate feeds the hub with plausible looking traffic.
|
||||||
|
func generate(ctx context.Context, hub *web.Hub) {
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
ticker := time.NewTicker(120 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
var id int64
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
for n := rng.Intn(6); n >= 0; n-- {
|
||||||
|
id++
|
||||||
|
udp := rng.Intn(3) == 0
|
||||||
|
proto := "tcp"
|
||||||
|
if udp {
|
||||||
|
proto = "udp"
|
||||||
|
}
|
||||||
|
hub.StreamNew(proto)
|
||||||
|
|
||||||
|
host := hosts[rng.Intn(len(hosts))]
|
||||||
|
props := web.Props{}
|
||||||
|
if udp {
|
||||||
|
props["dns"] = web.PropMap{
|
||||||
|
"qr": false,
|
||||||
|
"questions": []map[string]interface{}{{"name": host, "type": 1}},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
props["tls"] = web.PropMap{"req": map[string]interface{}{
|
||||||
|
"sni": host, "version": 771,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
hub.PropUpdate(props)
|
||||||
|
|
||||||
|
info := web.StreamInfo{
|
||||||
|
ID: id,
|
||||||
|
Proto: proto,
|
||||||
|
SrcIP: fmt.Sprintf("192.168.1.%d", 2+rng.Intn(60)),
|
||||||
|
SrcPort: uint16(20000 + rng.Intn(40000)),
|
||||||
|
DstIP: ips[rng.Intn(len(ips))],
|
||||||
|
DstPort: 443,
|
||||||
|
Props: props,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case rng.Intn(10) == 0:
|
||||||
|
hub.RuleLog(info, "log-suspicious")
|
||||||
|
hub.StreamAction(info, "block")
|
||||||
|
case rng.Intn(12) == 0:
|
||||||
|
hub.StreamAction(info, "drop")
|
||||||
|
case rng.Intn(14) == 0:
|
||||||
|
hub.StreamAction(info, "modify")
|
||||||
|
case rng.Intn(30) == 0:
|
||||||
|
hub.Error(info, "geoip-rule", "lookup timeout")
|
||||||
|
default:
|
||||||
|
hub.StreamAction(info, "allow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryRules is an in-memory web.RuleManager. Rules are compiled with the real
|
||||||
|
// ruleset compiler and the real analyzers, so expression errors show up here
|
||||||
|
// exactly like they would in the engine; only the "apply" step is faked.
|
||||||
|
type memoryRules struct {
|
||||||
|
raw string
|
||||||
|
rules []web.Rule
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedRules = `- name: block-malware
|
||||||
|
action: block
|
||||||
|
log: true
|
||||||
|
expr: 'tls != nil && tls.req != nil && string(tls.req.sni) endsWith ".bad.test"'
|
||||||
|
- name: block-ads-dns
|
||||||
|
action: drop
|
||||||
|
expr: 'dns != nil && any(dns.questions, {.name endsWith "ads.example.net"})'
|
||||||
|
- name: log-ssh
|
||||||
|
log: true
|
||||||
|
expr: 'ssh != nil'
|
||||||
|
`
|
||||||
|
|
||||||
|
func (m *memoryRules) init() error {
|
||||||
|
rules, err := parse(seedRules)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.raw, m.rules = seedRules, rules
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRules) Path() string { return "rules.yaml" }
|
||||||
|
|
||||||
|
func (m *memoryRules) Load() (string, []web.Rule, error) { return m.raw, m.rules, nil }
|
||||||
|
|
||||||
|
func (m *memoryRules) Validate(raw string) ([]web.Rule, error) { return parse(raw) }
|
||||||
|
|
||||||
|
func (m *memoryRules) Marshal(rules []web.Rule) (string, error) {
|
||||||
|
bs, err := yaml.Marshal(rules)
|
||||||
|
return string(bs), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *memoryRules) Apply(raw string) ([]web.Rule, error) {
|
||||||
|
rules, err := parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.raw, m.rules = raw, rules
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
analyzers = []analyzer.Analyzer{
|
||||||
|
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.SocksAnalyzer{}, &tcp.SSHAnalyzer{},
|
||||||
|
&tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, &udp.OpenVPNAnalyzer{},
|
||||||
|
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
|
||||||
|
}
|
||||||
|
modifiers = []modifier.Modifier{&modUDP.DNSModifier{}}
|
||||||
|
)
|
||||||
|
|
||||||
|
// parse compiles rules the same way the engine does.
|
||||||
|
func parse(raw string) ([]web.Rule, error) {
|
||||||
|
exprRules, err := ruleset.ExprRulesFromYAMLBytes([]byte(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse rules: %w", err)
|
||||||
|
}
|
||||||
|
_, err = ruleset.CompileExprRules(exprRules, analyzers, modifiers, &ruleset.BuiltinConfig{
|
||||||
|
Logger: nopRulesetLogger{},
|
||||||
|
GeoSiteFilename: os.Getenv("OPENGFW_GEOSITE"),
|
||||||
|
GeoIpFilename: os.Getenv("OPENGFW_GEOIP"),
|
||||||
|
ProtectedDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
return (&net.Dialer{}).DialContext(ctx, network, address)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]web.Rule, 0, len(exprRules))
|
||||||
|
for _, r := range exprRules {
|
||||||
|
wr := web.Rule{Name: r.Name, Action: r.Action, Log: r.Log, Expr: r.Expr}
|
||||||
|
if r.Modifier.Name != "" {
|
||||||
|
wr.Modifier = &web.RuleModifier{Name: r.Modifier.Name, Args: r.Modifier.Args}
|
||||||
|
}
|
||||||
|
out = append(out, wr)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type nopRulesetLogger struct{}
|
||||||
|
|
||||||
|
func (nopRulesetLogger) Log(ruleset.StreamInfo, string) {}
|
||||||
|
func (nopRulesetLogger) MatchError(ruleset.StreamInfo, string, error) {}
|
||||||
|
|
||||||
|
// geoData lists the geo databases, if they are available in the working
|
||||||
|
// directory (or wherever OPENGFW_GEOIP / OPENGFW_GEOSITE point).
|
||||||
|
func geoData() web.GeoData {
|
||||||
|
matcher := geo.NewGeoMatcher(os.Getenv("OPENGFW_GEOSITE"), os.Getenv("OPENGFW_GEOIP"))
|
||||||
|
var data web.GeoData
|
||||||
|
if entries, err := matcher.ListGeoIP(); err != nil {
|
||||||
|
data.IPError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.IP = append(data.IP, web.GeoEntry{Code: e.Code, Count: e.CIDRs})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if entries, err := matcher.ListGeoSite(); err != nil {
|
||||||
|
data.SiteError = err.Error()
|
||||||
|
} else {
|
||||||
|
for _, e := range entries {
|
||||||
|
data.Site = append(data.Site, web.GeoEntry{
|
||||||
|
Code: e.Code, Count: e.Domains, Attributes: e.Attributes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
import{_ as b}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as y}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.js";import{c as M,d as B,O as L,$ as j,a as o,p as t,u as e,l as r,F as f,r as p,b as u,w as s,_ as g,m as F,g as v,o as n,h,t as l,k as i,j as N,q}from"./index-DIqASLVQ.js";import{_ as k,a as x,b as z,c as w}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as A}from"./Skeleton.vue_vue_type_script_setup_true_lang-B_11D5UP.js";import{c as C}from"./format-B6sc_rZ4.js";/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const D=M("BoxesIcon",[["path",{d:"M2.97 12.92A2 2 0 0 0 2 14.63v3.24a2 2 0 0 0 .97 1.71l3 1.8a2 2 0 0 0 2.06 0L12 19v-5.5l-5-3-4.03 2.42Z",key:"lc1i9w"}],["path",{d:"m7 16.5-4.74-2.85",key:"1o9zyk"}],["path",{d:"m7 16.5 5-3",key:"va8pkn"}],["path",{d:"M7 16.5v5.17",key:"jnp8gn"}],["path",{d:"M12 13.5V19l3.97 2.38a2 2 0 0 0 2.06 0l3-1.8a2 2 0 0 0 .97-1.71v-3.24a2 2 0 0 0-.97-1.71L17 10.5l-5 3Z",key:"8zsnat"}],["path",{d:"m17 16.5-5-3",key:"8arw3v"}],["path",{d:"m17 16.5 4.74-2.85",key:"8rfmw"}],["path",{d:"M17 16.5v5.17",key:"k6z78m"}],["path",{d:"M7.97 4.42A2 2 0 0 0 7 6.13v4.37l5 3 5-3V6.13a2 2 0 0 0-.97-1.71l-3-1.8a2 2 0 0 0-2.06 0l-3 1.8Z",key:"1xygjf"}],["path",{d:"M12 8 7.26 5.15",key:"1vbdud"}],["path",{d:"m12 8 4.74-2.85",key:"3rx089"}],["path",{d:"M12 13.5V8",key:"1io7kd"}]]);/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const I=M("SquareFunctionIcon",[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",ry:"2",key:"1m3agn"}],["path",{d:"M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3",key:"m1af9g"}],["path",{d:"M9 11.2h5.7",key:"3zgcl2"}]]),S={key:0,class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},Z={class:"grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4"},E={class:"flex items-center justify-between gap-2"},O={class:"truncate font-mono text-sm font-medium"},T={class:"mt-2 text-xl font-semibold tabular-nums"},G={class:"text-muted-foreground text-xs"},H={class:"mt-3 grid gap-3 md:grid-cols-2"},J={key:0,class:"text-muted-foreground text-xs"},Y=B({__name:"AnalyzersView",setup(K){const{metrics:V}=q(),c=F(null);L(async()=>{try{c.value=await j.meta()}catch{c.value={analyzers:[],modifiers:[],actions:[],functions:[]}}});const _=v(()=>{var m;const d=new Map;for(const a of((m=V.value)==null?void 0:m.analyzers)??[])d.set(a.name,a.count);return d}),$=v(()=>{var d;return[...((d=c.value)==null?void 0:d.analyzers)??[]].sort((m,a)=>(_.value.get(a.name)??0)-(_.value.get(m.name)??0))});return(d,m)=>(n(),o("div",null,[t(b,{title:e(r)("analyzers.title"),description:e(r)("analyzers.subtitle")},null,8,["title","description"]),c.value?(n(),o(f,{key:1},[u("div",Z,[(n(!0),o(f,null,p($.value,a=>(n(),h(e(g),{key:a.name,class:"p-3.5 sm:p-4"},{default:s(()=>[u("div",E,[u("span",O,l(a.name),1),t(e(y),{variant:a.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:s(()=>[i(l(a.proto),1)]),_:2},1032,["variant"])]),u("p",T,l(e(C)(_.value.get(a.name)??0)),1),u("p",G,l(e(r)("analyzers.hits")),1)]),_:2},1024))),128))]),u("div",H,[t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(D),{class:"size-4"}),i(" "+l(e(r)("analyzers.modifiers")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.modifiersDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.modifiers,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a),1)]),_:2},1024))),128)),c.value.modifiers.length?N("",!0):(n(),o("span",J,"—"))]),_:1})]),_:1}),t(e(g),null,{default:s(()=>[t(e(k),null,{default:s(()=>[t(e(x),{class:"flex items-center gap-2"},{default:s(()=>[t(e(I),{class:"size-4"}),i(" "+l(e(r)("analyzers.functions")),1)]),_:1}),t(e(z),null,{default:s(()=>[i(l(e(r)("analyzers.functionsDesc")),1)]),_:1})]),_:1}),t(e(w),{class:"flex flex-wrap gap-1.5"},{default:s(()=>[(n(!0),o(f,null,p(c.value.functions,a=>(n(),h(e(y),{key:a,variant:"secondary",class:"font-mono"},{default:s(()=>[i(l(a)+"() ",1)]),_:2},1024))),128))]),_:1})]),_:1})])],64)):(n(),o("div",S,[(n(),o(f,null,p(8,a=>t(e(A),{key:a,class:"h-24"})),64))]))]))}});export{Y as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{d as o,a as n,e as d,u as e,f as s,H as l,o as p,a8 as c}from"./index-DIqASLVQ.js";const b=c("inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground",secondary:"border-transparent bg-secondary text-secondary-foreground",outline:"text-foreground",muted:"border-transparent bg-muted text-muted-foreground",allow:"border-allow/25 bg-allow/12 text-allow",block:"border-block/25 bg-block/12 text-block",drop:"border-drop/25 bg-drop/12 text-drop",modify:"border-modify/25 bg-modify/12 text-modify",log:"border-log/25 bg-log/12 text-log",tcp:"border-tcp/25 bg-tcp/12 text-tcp",udp:"border-udp/25 bg-udp/12 text-udp"}},defaultVariants:{variant:"default"}}),g=o({__name:"Badge",props:{variant:{},class:{}},setup(t){const r=t;return(a,u)=>(p(),n("span",{class:d(e(s)(e(b)({variant:r.variant}),r.class))},[l(a.$slots,"default")],2))}});export{g as _};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{d as a,o as n,a as r,e as c,u as o,f as p,H as l}from"./index-DIqASLVQ.js";const u=a({__name:"CardContent",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("px-4 pb-4 sm:px-5 sm:pb-5",e.class))},[l(t.$slots,"default")],2))}}),d=a({__name:"CardDescription",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("p",{class:c(o(p)("text-muted-foreground text-xs sm:text-sm",e.class))},[l(t.$slots,"default")],2))}}),f=a({__name:"CardHeader",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("div",{class:c(o(p)("flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5",e.class))},[l(t.$slots,"default")],2))}}),i=a({__name:"CardTitle",props:{class:{}},setup(s){const e=s;return(t,m)=>(n(),r("h3",{class:c(o(p)("text-sm leading-none font-semibold tracking-tight sm:text-base",e.class))},[l(t.$slots,"default")],2))}});export{f as _,i as a,d as b,u as c};
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
import{aB as h,aC as x,m as v,g as p,aD as R,al as S,d as g,aj as b,ap as w,aE as y,x as P,aF as k}from"./index-DIqASLVQ.js";function D(e,l){const t=typeof e=="string"&&!l?`${e}Context`:l,o=Symbol(t);return[a=>{const c=h(o,a);if(c||c===null)return c;throw new Error(`Injection \`${o.toString()}\` not found. Component must be used within ${Array.isArray(e)?`one of the following components: ${e.join(", ")}`:`\`${e}\``}`)},a=>(x(o,a),a)]}function _(){let e=document.activeElement;if(e==null)return null;for(;e!=null&&e.shadowRoot!=null&&e.shadowRoot.activeElement!=null;)e=e.shadowRoot.activeElement;return e}const[A]=D("ConfigProvider");function z(e){const l=A({dir:v("ltr")});return p(()=>{var t;return(e==null?void 0:e.value)||((t=l.dir)==null?void 0:t.value)||"ltr"})}function K(e,l="reka"){var n;let t;const o=A({useId:void 0});return o.useId?t=o.useId():t=(n=R)==null?void 0:n(),l?`${l}-${t}`:t}function I(){const e=v(),l=p(()=>{var t,o;return["#text","#comment"].includes((t=e.value)==null?void 0:t.$el.nodeName)?(o=e.value)==null?void 0:o.$el.nextElementSibling:S(e)});return{primitiveElement:e,currentElement:l}}const $="data-reka-collection-item";function q(e={}){const{key:l="",isProvider:t=!1}=e,o=`${l}CollectionProvider`;let n;if(t){const u=v(new Map);n={collectionRef:v(),itemMap:u},x(o,n)}else n=h(o);const C=(u=!1)=>{const r=n.collectionRef.value;if(!r)return[];const f=Array.from(r.querySelectorAll(`[${$}]`)),m=new Map(f.map((i,E)=>[i,E])),d=Array.from(n.itemMap.value.values()).sort((i,E)=>(m.get(i.ref)??-1)-(m.get(E.ref)??-1));return u?d:d.filter(i=>i.ref.dataset.disabled!=="")},a=g({name:"CollectionSlot",inheritAttrs:!1,setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return P(s,()=>{n.collectionRef.value=s.value}),()=>w(y,{ref:m,...f},r)}}),c=g({name:"CollectionItem",inheritAttrs:!1,props:{value:{validator:()=>!0}},setup(u,{slots:r,attrs:f}){const{primitiveElement:m,currentElement:s}=I();return b(d=>{if(s.value){const i=k(s.value);n.itemMap.value.set(i,{ref:s.value,value:u.value}),d(()=>n.itemMap.value.delete(i))}}),()=>w(y,{...f,[$]:"",ref:m},r)}}),M=p(()=>Array.from(n.itemMap.value.values())),j=p(()=>n.itemMap.value.size);return{getItems:C,reactiveItems:M,itemMapSize:j,CollectionSlot:a,CollectionItem:c}}export{q as a,z as b,D as c,K as d,_ as g,A as i,I as u};
|
||||||
+26
File diff suppressed because one or more lines are too long
@@ -0,0 +1,6 @@
|
|||||||
|
import{_ as a}from"./Badge.vue_vue_type_script_setup_true_lang-DSNeqlXx.js";import{c as h,d as x,o,h as m,u as t,w as c,k as i,t as n,g as v,l as d,a as k,b as r,p,j as f}from"./index-DIqASLVQ.js";import{t as b,e as _}from"./format-B6sc_rZ4.js";/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const w=h("ArrowRightIcon",[["path",{d:"M5 12h14",key:"1ays0h"}],["path",{d:"m12 5 7 7-7 7",key:"xquz4c"}]]),y=x({__name:"ActionBadge",props:{event:{}},setup(l){const e=l,u=v(()=>{if(e.event.kind==="error")return"block";if(e.event.kind==="log")return"log";switch(e.event.action){case"allow":return"allow";case"block":return"block";case"drop":return"drop";case"modify":return"modify";default:return"muted"}}),s=v(()=>e.event.kind==="error"?d("action.error"):e.event.kind==="log"?d("action.log"):d(`action.${e.event.action??"maybe"}`));return(g,N)=>(o(),m(t(a),{variant:u.value,class:"uppercase"},{default:c(()=>[i(n(s.value),1)]),_:1},8,["variant"]))}}),B={class:"flex items-center gap-2"},I={class:"text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums"},$={class:"flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs"},A={class:"text-muted-foreground truncate"},C={class:"truncate"},E=x({__name:"EventItem",props:{event:{}},emits:["select"],setup(l){const e=l;return(u,s)=>(o(),k("button",{type:"button",class:"hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3",onClick:s[0]||(s[0]=g=>u.$emit("select",e.event))},[r("div",B,[r("span",I,n(t(b)(e.event.time)),1),p(y,{event:e.event},null,8,["event"]),p(t(a),{variant:e.event.proto==="udp"?"udp":"tcp",class:"uppercase"},{default:c(()=>[i(n(e.event.proto),1)]),_:1},8,["variant"]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"max-w-32 truncate sm:hidden"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]),r("div",$,[r("span",A,n(t(_)(e.event.srcIP,e.event.srcPort)),1),p(t(w),{class:"text-muted-foreground/60 size-3 shrink-0"}),r("span",C,n(e.event.host||t(_)(e.event.dstIP,e.event.dstPort)),1)]),e.event.rule?(o(),m(t(a),{key:0,variant:"outline",class:"hidden max-w-40 truncate sm:inline-flex"},{default:c(()=>[i(n(e.event.rule),1)]),_:1})):f("",!0)]))}});export{w as A,E as _,y as a};
|
||||||
+16
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{d as c,a as t,b as n,t as i,j as a,H as d,o as s}from"./index-DIqASLVQ.js";const l={class:"mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6"},m={class:"min-w-0"},p={class:"text-lg font-semibold tracking-tight sm:text-2xl"},_={key:0,class:"text-muted-foreground mt-0.5 text-xs sm:text-sm"},x={key:0,class:"flex shrink-0 items-center gap-2"},g=c({__name:"PageHeader",props:{title:{},description:{}},setup(r){const e=r;return(o,f)=>(s(),t("div",l,[n("div",m,[n("h1",p,i(e.title),1),e.description?(s(),t("p",_,i(e.description),1)):a("",!0)]),o.$slots.actions?(s(),t("div",x,[d(o.$slots,"actions")])):a("",!0)]))}});export{g as _};
|
||||||
+46
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+16
@@ -0,0 +1,16 @@
|
|||||||
|
import{c as y,d as M,o as u,a as r,e as L,u as t,f as T,O as F,$ as z,p as a,l as e,w as n,_ as f,m as R,k as c,t as o,b as i,F as _,r as p,h as D,i as N,s as $,a9 as E,a0 as q,g as v,aa as H,v as U,ab as A,ac as G}from"./index-DIqASLVQ.js";import{_ as J}from"./PageHeader.vue_vue_type_script_setup_true_lang-CD1KTXEu.js";import{_ as x,a as h,c as b,b as K}from"./CardTitle.vue_vue_type_script_setup_true_lang-DKXrNKGt.js";import{_ as P}from"./Select.vue_vue_type_script_setup_true_lang-CzaVl8rJ.js";import"./Collection-CwtwoTtI.js";/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const W=y("BookOpenIcon",[["path",{d:"M12 7v14",key:"1akyts"}],["path",{d:"M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z",key:"ruj8y"}]]);/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const X=y("LogOutIcon",[["path",{d:"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4",key:"1uf3rs"}],["polyline",{points:"16 17 21 12 16 7",key:"1gabdz"}],["line",{x1:"21",x2:"9",y1:"12",y2:"12",key:"1uyos4"}]]);/**
|
||||||
|
* @license lucide-vue-next v0.469.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const Y=y("MonitorIcon",[["rect",{width:"20",height:"14",x:"2",y:"3",rx:"2",key:"48i651"}],["line",{x1:"8",x2:"16",y1:"21",y2:"21",key:"1svkeh"}],["line",{x1:"12",x2:"12",y1:"17",y2:"21",key:"vw1qmm"}]]),Z=M({__name:"Separator",props:{class:{},orientation:{default:"horizontal"}},setup(k){const g=k;return(w,S)=>(u(),r("div",{role:"separator",class:L(t(T)("bg-border shrink-0",g.orientation==="vertical"?"h-full w-px":"h-px w-full",g.class))},null,2))}}),ee={class:"max-w-3xl"},te={class:"flex flex-col gap-2"},se={class:"text-sm font-medium"},ae={class:"bg-muted grid grid-cols-3 gap-1 rounded-lg p-1"},le=["onClick"],ne={class:"flex items-center justify-between gap-4"},oe={class:"text-sm font-medium"},ie={class:"flex flex-col"},ue={class:"text-muted-foreground shrink-0"},re={class:"truncate font-mono text-xs sm:text-[13px]"},ce={class:"flex flex-col"},de={class:"text-muted-foreground shrink-0"},ge={class:"truncate font-mono text-xs sm:text-[13px]"},he=M({__name:"SettingsView",setup(k){const g=U(),{theme:w,setTheme:S}=E(),{lang:V,setLang:C}=q(),m=R(null);F(async()=>{try{m.value=await z.info()}catch{}});const B=v(()=>[{value:"light",label:e("settings.themeLight"),icon:A},{value:"dark",label:e("settings.themeDark"),icon:G},{value:"system",label:e("settings.themeSystem"),icon:Y}]),O=[{value:"en",label:"English"},{value:"zh",label:"中文"}],Q=v(()=>{const s=m.value;return s?[{label:e("settings.version"),value:s.commit?`${s.version} (${s.commit})`:s.version},{label:e("settings.platform"),value:s.platform},{label:e("settings.goVersion"),value:s.goVersion},{label:e("settings.hostname"),value:s.hostname},{label:e("settings.ruleFile"),value:s.ruleFile}]:[]}),j=v(()=>{var l;const s=(l=m.value)==null?void 0:l.config;if(!s)return[];const d=[{label:e("settings.workers"),value:s.workers>0?String(s.workers):e("settings.auto")},{label:e("settings.workerQueueSize"),value:s.workerQueueSize>0?String(s.workerQueueSize):e("settings.auto")},{label:e("settings.ioQueueSize"),value:s.ioQueueSize>0?String(s.ioQueueSize):e("settings.auto")},{label:e("settings.ioLocal"),value:s.ioLocal?e("common.enabled"):e("common.disabled")},{label:e("settings.ioRST"),value:s.ioRST?e("common.enabled"):e("common.disabled")},{label:e("settings.udpMaxStreams"),value:s.udpMaxStreams>0?String(s.udpMaxStreams):e("settings.auto")}];return s.geoip&&d.push({label:e("settings.geoip"),value:s.geoip}),s.geosite&&d.push({label:e("settings.geosite"),value:s.geosite}),d});async function I(){H(),await z.logout(),await g.replace({name:"login"})}return(s,d)=>(u(),r("div",ee,[a(J,{title:t(e)("settings.title"),description:t(e)("settings.subtitle")},null,8,["title","description"]),a(t(f),null,{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.appearance")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-col gap-4"},{default:n(()=>[i("div",te,[i("span",se,o(t(e)("settings.theme")),1),i("div",ae,[(u(!0),r(_,null,p(B.value,l=>(u(),r("button",{key:l.value,type:"button",class:L(["flex items-center justify-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium transition-colors sm:text-sm",t(w)===l.value?"bg-background text-foreground shadow-sm":"text-muted-foreground hover:text-foreground"]),onClick:me=>t(S)(l.value)},[(u(),D(N(l.icon),{class:"size-3.5"})),c(" "+o(l.label),1)],10,le))),128))])]),i("div",ne,[i("span",oe,o(t(e)("settings.language")),1),a(t(P),{"model-value":t(V),options:O,class:"w-36","aria-label":t(e)("settings.language"),"onUpdate:modelValue":d[0]||(d[0]=l=>t(C)(l))},null,8,["model-value","aria-label"])])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.instance")),1)]),_:1}),a(t(K),null,{default:n(()=>[c(o(t(e)("settings.engineConfig")),1)]),_:1})]),_:1}),a(t(b),null,{default:n(()=>[i("dl",ie,[(u(!0),r(_,null,p(Q.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",ue,o(l.label),1),i("dd",re,o(l.value),1)]))),128))]),a(t(Z),{class:"my-3"}),i("dl",ce,[(u(!0),r(_,null,p(j.value,l=>(u(),r("div",{key:l.label,class:"flex items-start justify-between gap-4 py-1.5 text-sm"},[i("dt",de,o(l.label),1),i("dd",ge,o(l.value),1)]))),128))])]),_:1})]),_:1}),a(t(f),{class:"mt-3"},{default:n(()=>[a(t(x),null,{default:n(()=>[a(t(h),null,{default:n(()=>[c(o(t(e)("settings.session")),1)]),_:1})]),_:1}),a(t(b),{class:"flex flex-wrap gap-2"},{default:n(()=>[a(t($),{variant:"destructive",onClick:I},{default:n(()=>[a(t(X),{class:"size-4"}),c(" "+o(t(e)("settings.logout")),1)]),_:1}),a(t($),{variant:"outline",as:"a",href:"https://gfw.dev/",target:"_blank",rel:"noreferrer"},{default:n(()=>[a(t(W),{class:"size-4"}),c(" "+o(t(e)("settings.docs")),1)]),_:1})]),_:1})]),_:1})]))}});export{he as default};
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{d as a,o as n,a as o,e as t,u as c,f as r}from"./index-DIqASLVQ.js";const u=a({__name:"Skeleton",props:{class:{}},setup(e){const s=e;return(l,m)=>(n(),o("div",{class:t(c(r)("bg-muted animate-pulse rounded-md",s.class))},null,2))}});export{u as _};
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
function o(t){if(!Number.isFinite(t))return"0";if(t<1e3)return String(t);const e=["k","M","G","T"];let n=t,r=-1;for(;n>=1e3&&r<e.length-1;)n/=1e3,r++;return`${n>=100?Math.round(n):n.toFixed(1).replace(/\.0$/,"")}${e[r]}`}function i(t,e){return e?Math.round(t/e*100):0}function u(t){if(t<60)return`${Math.max(0,Math.floor(t))}s`;const e=Math.floor(t/86400),n=Math.floor(t%86400/3600),r=Math.floor(t%3600/60);return e>0?`${e}d ${n}h`:n>0?`${n}h ${r}m`:`${r}m`}function a(t){return new Date(t).toLocaleTimeString(void 0,{hour12:!1})}function f(t){const e=new Date(t);return`${e.toLocaleDateString()} ${e.toLocaleTimeString(void 0,{hour12:!1})}`}function c(t,e){return t.includes(":")?`[${t}]:${e}`:`${t}:${e}`}export{f as a,o as c,u as d,c as e,i as p,a as t};
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+106
File diff suppressed because one or more lines are too long
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||||
|
<path
|
||||||
|
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#a1a1aa"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
Vendored
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>OpenGFW</title>
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint to avoid a flash.
|
||||||
|
;(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||||
|
var dark =
|
||||||
|
t === 'dark' ||
|
||||||
|
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.classList.toggle('dark', dark)
|
||||||
|
} catch (e) {}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
<script type="module" crossorigin src="/assets/index-DIqASLVQ.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-DDyrZ70V.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// distFS holds the compiled web UI. Run `make web` (or `npm run build` inside
|
||||||
|
// web/frontend) to refresh it; the checked-in placeholder simply tells the user
|
||||||
|
// that the UI has not been built yet.
|
||||||
|
//
|
||||||
|
//go:embed all:dist
|
||||||
|
var distFS embed.FS
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=5"
|
||||||
|
/>
|
||||||
|
<meta name="theme-color" content="#09090b" />
|
||||||
|
<meta name="color-scheme" content="light dark" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
|
||||||
|
<title>OpenGFW</title>
|
||||||
|
<script>
|
||||||
|
// Apply the stored theme before first paint to avoid a flash.
|
||||||
|
;(function () {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('opengfw.theme') || 'system'
|
||||||
|
var dark =
|
||||||
|
t === 'dark' ||
|
||||||
|
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||||
|
document.documentElement.classList.toggle('dark', dark)
|
||||||
|
} catch (e) {}
|
||||||
|
})()
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+2501
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "opengfw-web",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"description": "Web UI for OpenGFW",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc -b --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-vue-next": "^0.469.0",
|
||||||
|
"reka-ui": "^2.5.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"vue": "^3.5.13",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
|
"@types/node": "^22.10.5",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.1",
|
||||||
|
"tailwindcss": "^4.1.11",
|
||||||
|
"typescript": "~5.7.3",
|
||||||
|
"vite": "^6.0.7",
|
||||||
|
"vue-tsc": "^2.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#18181b" />
|
||||||
|
<path
|
||||||
|
d="M16 5l9 3.5v7.2c0 5.3-3.6 9.9-9 11.3-5.4-1.4-9-6-9-11.3V8.5L16 5z"
|
||||||
|
fill="none"
|
||||||
|
stroke="#a1a1aa"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M11 16h10M16 11v10" stroke="#ef4444" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 388 B |
@@ -0,0 +1,10 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { RouterView } from 'vue-router'
|
||||||
|
|
||||||
|
import Toaster from '@/components/Toaster.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
<Toaster />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.65rem;
|
||||||
|
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||||
|
--primary: oklch(0.21 0.006 285.885);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.967 0.001 286.375);
|
||||||
|
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--muted: oklch(0.967 0.001 286.375);
|
||||||
|
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||||
|
--accent: oklch(0.967 0.001 286.375);
|
||||||
|
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(0.92 0.004 286.32);
|
||||||
|
--input: oklch(0.92 0.004 286.32);
|
||||||
|
--ring: oklch(0.705 0.015 286.067);
|
||||||
|
|
||||||
|
/* Verdict colors, shared by badges, charts and the live feed. */
|
||||||
|
--allow: oklch(0.63 0.14 155);
|
||||||
|
--block: oklch(0.58 0.22 27);
|
||||||
|
--drop: oklch(0.72 0.16 70);
|
||||||
|
--modify: oklch(0.6 0.16 255);
|
||||||
|
--log: oklch(0.62 0.11 215);
|
||||||
|
--tcp: oklch(0.62 0.15 265);
|
||||||
|
--udp: oklch(0.68 0.13 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.141 0.005 285.823);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.19 0.006 285.885);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.21 0.006 285.885);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.92 0.004 286.32);
|
||||||
|
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||||
|
--secondary: oklch(0.274 0.006 286.033);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.274 0.006 286.033);
|
||||||
|
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||||
|
--accent: oklch(0.274 0.006 286.033);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--destructive-foreground: oklch(0.985 0 0);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.552 0.016 285.938);
|
||||||
|
|
||||||
|
--allow: oklch(0.72 0.16 155);
|
||||||
|
--block: oklch(0.68 0.2 22);
|
||||||
|
--drop: oklch(0.79 0.15 75);
|
||||||
|
--modify: oklch(0.7 0.15 255);
|
||||||
|
--log: oklch(0.72 0.11 215);
|
||||||
|
--tcp: oklch(0.7 0.15 265);
|
||||||
|
--udp: oklch(0.76 0.12 190);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-allow: var(--allow);
|
||||||
|
--color-block: var(--block);
|
||||||
|
--color-drop: var(--drop);
|
||||||
|
--color-modify: var(--modify);
|
||||||
|
--color-log: var(--log);
|
||||||
|
--color-tcp: var(--tcp);
|
||||||
|
--color-udp: var(--udp);
|
||||||
|
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
|
||||||
|
--font-mono:
|
||||||
|
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||||
|
|
||||||
|
--animate-fade-in: fade-in 0.18s ease-out;
|
||||||
|
--animate-slide-up: slide-up 0.22s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--animate-slide-in-right: slide-in-right 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--animate-slide-in-left: slide-in-left 0.24s cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
|
--animate-pulse-dot: pulse-dot 1.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-right {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-in-left {
|
||||||
|
from {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-dot {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
border-color: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-background);
|
||||||
|
color: var(--color-foreground);
|
||||||
|
font-family:
|
||||||
|
ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||||
|
'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklab, var(--color-muted-foreground) 35%, transparent);
|
||||||
|
border: 3px solid transparent;
|
||||||
|
background-clip: content-box;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: color-mix(in oklab, var(--color-muted-foreground) 55%, transparent);
|
||||||
|
background-clip: content-box;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility safe-bottom {
|
||||||
|
padding-bottom: max(env(safe-area-inset-bottom), 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
@utility safe-top {
|
||||||
|
padding-top: max(env(safe-area-inset-top), 0px);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import type { LiveEvent } from '@/lib/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ event: LiveEvent }>()
|
||||||
|
|
||||||
|
const variant = computed(() => {
|
||||||
|
if (props.event.kind === 'error') return 'block'
|
||||||
|
if (props.event.kind === 'log') return 'log'
|
||||||
|
switch (props.event.action) {
|
||||||
|
case 'allow':
|
||||||
|
return 'allow'
|
||||||
|
case 'block':
|
||||||
|
return 'block'
|
||||||
|
case 'drop':
|
||||||
|
return 'drop'
|
||||||
|
case 'modify':
|
||||||
|
return 'modify'
|
||||||
|
default:
|
||||||
|
return 'muted'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
if (props.event.kind === 'error') return t('action.error')
|
||||||
|
if (props.event.kind === 'log') return t('action.log')
|
||||||
|
return t(`action.${props.event.action ?? 'maybe'}`)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Badge :variant="variant" class="uppercase">{{ label }}</Badge>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Activity, LayoutDashboard, ListFilter, Radio, Settings2 } from 'lucide-vue-next'
|
||||||
|
import { computed, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { RouterLink, RouterView, useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
import StatusPill from '@/components/StatusPill.vue'
|
||||||
|
import { startLive, stopLive } from '@/composables/useLive'
|
||||||
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
useTheme()
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
{ to: '/', label: t('nav.dashboard'), icon: LayoutDashboard },
|
||||||
|
{ to: '/events', label: t('nav.events'), icon: Radio },
|
||||||
|
{ to: '/rules', label: t('nav.rules'), icon: ListFilter },
|
||||||
|
{ to: '/analyzers', label: t('nav.analyzers'), icon: Activity },
|
||||||
|
{ to: '/settings', label: t('nav.settings'), icon: Settings2 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const title = computed(() => {
|
||||||
|
const item = items.value.find((i) => i.to === route.path)
|
||||||
|
return item?.label ?? t('app.name')
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(startLive)
|
||||||
|
onUnmounted(stopLive)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-background min-h-dvh">
|
||||||
|
<!-- Desktop sidebar -->
|
||||||
|
<aside
|
||||||
|
class="bg-card/40 fixed inset-y-0 left-0 z-40 hidden w-56 flex-col border-r px-3 py-4 md:flex lg:w-60"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2 px-2 pb-4">
|
||||||
|
<div
|
||||||
|
class="bg-primary text-primary-foreground flex size-8 items-center justify-center rounded-lg font-bold"
|
||||||
|
>
|
||||||
|
G
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="truncate text-sm font-semibold">{{ t('app.name') }}</p>
|
||||||
|
<p class="text-muted-foreground truncate text-[11px]">{{ t('app.tagline') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="flex flex-1 flex-col gap-0.5">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-sm font-medium transition-colors',
|
||||||
|
route.path === item.to
|
||||||
|
? 'bg-accent text-accent-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component :is="item.icon" class="size-4 shrink-0" />
|
||||||
|
{{ item.label }}
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="px-2 pt-3">
|
||||||
|
<StatusPill />
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="flex min-h-dvh flex-col md:pl-56 lg:pl-60">
|
||||||
|
<!-- Mobile top bar -->
|
||||||
|
<header
|
||||||
|
class="bg-background/85 safe-top sticky top-0 z-30 border-b backdrop-blur-md md:hidden"
|
||||||
|
>
|
||||||
|
<div class="flex h-12 items-center justify-between gap-3 px-4">
|
||||||
|
<span class="truncate text-sm font-semibold">{{ title }}</span>
|
||||||
|
<StatusPill />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="flex-1 px-4 pt-4 pb-24 sm:px-6 sm:pt-6 md:pb-8">
|
||||||
|
<RouterView v-slot="{ Component }">
|
||||||
|
<component :is="Component" class="animate-fade-in" />
|
||||||
|
</RouterView>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile bottom navigation -->
|
||||||
|
<nav
|
||||||
|
class="bg-background/90 safe-bottom fixed inset-x-0 bottom-0 z-40 border-t backdrop-blur-md md:hidden"
|
||||||
|
>
|
||||||
|
<div class="grid grid-cols-5">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex flex-col items-center gap-0.5 py-2 text-[10px] font-medium transition-colors',
|
||||||
|
route.path === item.to ? 'text-foreground' : 'text-muted-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.icon"
|
||||||
|
:class="cn('size-5', route.path === item.to && 'text-primary')"
|
||||||
|
/>
|
||||||
|
<span class="truncate">{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { compact } from '@/lib/format'
|
||||||
|
import type { NameCount } from '@/lib/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ items: NameCount[]; tone?: 'primary' | 'block' | 'allow'; empty?: string }>(),
|
||||||
|
{ tone: 'primary' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const max = computed(() => Math.max(1, ...props.items.map((i) => i.count)))
|
||||||
|
|
||||||
|
const bars: Record<string, string> = {
|
||||||
|
primary: 'bg-primary/15',
|
||||||
|
block: 'bg-block/15',
|
||||||
|
allow: 'bg-allow/15',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="props.items.length" class="flex flex-col gap-1">
|
||||||
|
<div
|
||||||
|
v-for="item in props.items"
|
||||||
|
:key="item.name"
|
||||||
|
class="relative flex items-center justify-between gap-3 overflow-hidden rounded-md px-2 py-1.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="cn('absolute inset-y-0 left-0 rounded-md', bars[props.tone])"
|
||||||
|
:style="{ width: `${Math.max(4, (item.count / max) * 100)}%` }"
|
||||||
|
/>
|
||||||
|
<span class="relative truncate font-mono text-xs sm:text-[13px]">{{ item.name }}</span>
|
||||||
|
<span class="text-muted-foreground relative shrink-0 text-xs tabular-nums">{{
|
||||||
|
compact(item.count)
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-muted-foreground py-6 text-center text-xs">
|
||||||
|
{{ props.empty ?? '—' }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowRight } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import ActionBadge from '@/components/ActionBadge.vue'
|
||||||
|
import { Badge } from '@/components/ui'
|
||||||
|
import { endpoint, time } from '@/lib/format'
|
||||||
|
import type { LiveEvent } from '@/lib/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ event: LiveEvent }>()
|
||||||
|
defineEmits<{ select: [event: LiveEvent] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="hover:bg-accent/60 focus-visible:ring-ring/40 flex w-full flex-col gap-1.5 rounded-lg px-2.5 py-2 text-left transition-colors outline-none focus-visible:ring-[3px] sm:flex-row sm:items-center sm:gap-3"
|
||||||
|
@click="$emit('select', props.event)"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-muted-foreground shrink-0 font-mono text-[11px] tabular-nums">
|
||||||
|
{{ time(props.event.time) }}
|
||||||
|
</span>
|
||||||
|
<ActionBadge :event="props.event" />
|
||||||
|
<Badge :variant="props.event.proto === 'udp' ? 'udp' : 'tcp'" class="uppercase">
|
||||||
|
{{ props.event.proto }}
|
||||||
|
</Badge>
|
||||||
|
<Badge v-if="props.event.rule" variant="outline" class="max-w-32 truncate sm:hidden">
|
||||||
|
{{ props.event.rule }}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 flex-1 items-center gap-1.5 font-mono text-xs">
|
||||||
|
<span class="text-muted-foreground truncate">
|
||||||
|
{{ endpoint(props.event.srcIP, props.event.srcPort) }}
|
||||||
|
</span>
|
||||||
|
<ArrowRight class="text-muted-foreground/60 size-3 shrink-0" />
|
||||||
|
<span class="truncate">
|
||||||
|
{{ props.event.host || endpoint(props.event.dstIP, props.event.dstPort) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Badge v-if="props.event.rule" variant="outline" class="hidden max-w-40 truncate sm:inline-flex">
|
||||||
|
{{ props.event.rule }}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{ title: string; description?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mb-4 flex flex-wrap items-end justify-between gap-3 sm:mb-6">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<h1 class="text-lg font-semibold tracking-tight sm:text-2xl">{{ props.title }}</h1>
|
||||||
|
<p v-if="props.description" class="text-muted-foreground mt-0.5 text-xs sm:text-sm">
|
||||||
|
{{ props.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="flex shrink-0 items-center gap-2">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import { Card } from '@/components/ui'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
hint?: string
|
||||||
|
icon?: Component
|
||||||
|
tone?: 'default' | 'allow' | 'block' | 'drop' | 'modify'
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tones: Record<string, string> = {
|
||||||
|
default: 'text-muted-foreground',
|
||||||
|
allow: 'text-allow',
|
||||||
|
block: 'text-block',
|
||||||
|
drop: 'text-drop',
|
||||||
|
modify: 'text-modify',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Card class="p-3.5 sm:p-4">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<span class="text-muted-foreground text-xs font-medium sm:text-sm">{{ props.label }}</span>
|
||||||
|
<component
|
||||||
|
:is="props.icon"
|
||||||
|
v-if="props.icon"
|
||||||
|
:class="cn('size-4 shrink-0', tones[props.tone ?? 'default'])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="mt-1.5 flex items-baseline gap-1.5">
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'text-xl font-semibold tracking-tight tabular-nums sm:text-2xl',
|
||||||
|
props.tone && props.tone !== 'default' ? tones[props.tone] : '',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>{{ props.value }}</span
|
||||||
|
>
|
||||||
|
<span v-if="props.hint" class="text-muted-foreground truncate text-xs">{{
|
||||||
|
props.hint
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useLive } from '@/composables/useLive'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const { status } = useLive()
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
switch (status.value) {
|
||||||
|
case 'open':
|
||||||
|
return t('common.live')
|
||||||
|
case 'connecting':
|
||||||
|
return t('common.connecting')
|
||||||
|
default:
|
||||||
|
return t('common.offline')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-[11px] font-medium',
|
||||||
|
status === 'open'
|
||||||
|
? 'border-allow/30 bg-allow/10 text-allow'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'border-drop/30 bg-drop/10 text-drop'
|
||||||
|
: 'border-block/30 bg-block/10 text-block',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'size-1.5 rounded-full',
|
||||||
|
status === 'open'
|
||||||
|
? 'bg-allow animate-pulse-dot'
|
||||||
|
: status === 'connecting'
|
||||||
|
? 'bg-drop animate-pulse-dot'
|
||||||
|
: 'bg-block',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
{{ label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { CheckCircle2, TriangleAlert, X } from 'lucide-vue-next'
|
||||||
|
|
||||||
|
import { dismiss, useToast } from '@/composables/useToast'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const { toasts } = useToast()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-x-0 top-0 z-100 flex flex-col items-center gap-2 p-3 sm:top-auto sm:right-0 sm:bottom-0 sm:left-auto sm:items-end sm:p-4"
|
||||||
|
>
|
||||||
|
<TransitionGroup
|
||||||
|
enter-active-class="transition duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 -translate-y-2 sm:translate-y-2"
|
||||||
|
leave-active-class="transition duration-150 ease-in absolute"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="item in toasts"
|
||||||
|
:key="item.id"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-popover text-popover-foreground pointer-events-auto flex w-full max-w-sm items-start gap-2.5 rounded-lg border p-3 shadow-lg',
|
||||||
|
item.variant === 'error' && 'border-block/40',
|
||||||
|
item.variant === 'success' && 'border-allow/40',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<CheckCircle2 v-if="item.variant === 'success'" class="text-allow mt-0.5 size-4 shrink-0" />
|
||||||
|
<TriangleAlert
|
||||||
|
v-else-if="item.variant === 'error'"
|
||||||
|
class="text-block mt-0.5 size-4 shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium">{{ item.title }}</p>
|
||||||
|
<p v-if="item.description" class="text-muted-foreground mt-0.5 text-xs break-words">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-muted-foreground hover:text-foreground -m-1 p-1"
|
||||||
|
@click="dismiss(item.id)"
|
||||||
|
>
|
||||||
|
<X class="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { compact, time } from '@/lib/format'
|
||||||
|
import type { Bucket } from '@/lib/types'
|
||||||
|
|
||||||
|
const props = defineProps<{ buckets: Bucket[] }>()
|
||||||
|
|
||||||
|
const W = 600
|
||||||
|
const H = 160
|
||||||
|
const PAD_TOP = 8
|
||||||
|
const PAD_BOTTOM = 16
|
||||||
|
|
||||||
|
const hover = ref<number | null>(null)
|
||||||
|
|
||||||
|
const points = computed(() => props.buckets ?? [])
|
||||||
|
const max = computed(() => Math.max(1, ...points.value.map((b) => b.tcp + b.udp)))
|
||||||
|
const hasData = computed(() => points.value.some((b) => b.tcp + b.udp + b.blocked > 0))
|
||||||
|
|
||||||
|
function x(i: number): number {
|
||||||
|
const n = points.value.length
|
||||||
|
return n <= 1 ? 0 : (i / (n - 1)) * W
|
||||||
|
}
|
||||||
|
|
||||||
|
function y(value: number): number {
|
||||||
|
const usable = H - PAD_TOP - PAD_BOTTOM
|
||||||
|
return PAD_TOP + usable - (value / max.value) * usable
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Builds a closed area path for a series of stacked values. */
|
||||||
|
function area(values: number[]): string {
|
||||||
|
if (values.length === 0) return ''
|
||||||
|
const top = values.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||||
|
const base = `L${W},${y(0)} L0,${y(0)} Z`
|
||||||
|
return top.join(' ') + ' ' + base
|
||||||
|
}
|
||||||
|
|
||||||
|
function line(values: number[]): string {
|
||||||
|
return values
|
||||||
|
.map((v, i) => `${i === 0 ? 'M' : 'L'}${x(i).toFixed(1)},${y(v).toFixed(1)}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPath = computed(() => area(points.value.map((b) => b.tcp + b.udp)))
|
||||||
|
const tcpPath = computed(() => area(points.value.map((b) => b.tcp)))
|
||||||
|
const blockedPath = computed(() => line(points.value.map((b) => b.blocked)))
|
||||||
|
|
||||||
|
const active = computed(() => (hover.value === null ? null : points.value[hover.value]))
|
||||||
|
|
||||||
|
function onMove(event: PointerEvent) {
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
|
const ratio = (event.clientX - rect.left) / rect.width
|
||||||
|
const index = Math.round(ratio * (points.value.length - 1))
|
||||||
|
hover.value = Math.min(points.value.length - 1, Math.max(0, index))
|
||||||
|
}
|
||||||
|
|
||||||
|
const legend = computed(() => {
|
||||||
|
const sum = (key: 'tcp' | 'udp' | 'blocked') =>
|
||||||
|
points.value.reduce((acc, b) => acc + b[key], 0)
|
||||||
|
return [
|
||||||
|
{ label: t('dashboard.tcp'), color: 'bg-tcp', value: sum('tcp') },
|
||||||
|
{ label: t('dashboard.udp'), color: 'bg-udp', value: sum('udp') },
|
||||||
|
{ label: t('dashboard.blocked'), color: 'bg-block', value: sum('blocked') },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="relative touch-pan-y"
|
||||||
|
@pointermove="onMove"
|
||||||
|
@pointerdown="onMove"
|
||||||
|
@pointerleave="hover = null"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
:viewBox="`0 0 ${W} ${H}`"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
class="h-36 w-full sm:h-44"
|
||||||
|
role="img"
|
||||||
|
>
|
||||||
|
<line
|
||||||
|
v-for="frac in [0, 0.5, 1]"
|
||||||
|
:key="frac"
|
||||||
|
:x1="0"
|
||||||
|
:x2="W"
|
||||||
|
:y1="y(max * frac)"
|
||||||
|
:y2="y(max * frac)"
|
||||||
|
class="stroke-border"
|
||||||
|
stroke-width="1"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
stroke-dasharray="3 4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<path :d="totalPath" class="fill-udp/25" />
|
||||||
|
<path :d="tcpPath" class="fill-tcp/45" />
|
||||||
|
<path
|
||||||
|
:d="blockedPath"
|
||||||
|
class="stroke-block"
|
||||||
|
fill="none"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="hover !== null">
|
||||||
|
<line
|
||||||
|
:x1="x(hover)"
|
||||||
|
:x2="x(hover)"
|
||||||
|
:y1="PAD_TOP"
|
||||||
|
:y2="y(0)"
|
||||||
|
class="stroke-foreground/40"
|
||||||
|
stroke-width="1"
|
||||||
|
vector-effect="non-scaling-stroke"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="text-muted-foreground pointer-events-none absolute top-0 left-0 text-[10px] tabular-nums"
|
||||||
|
>
|
||||||
|
{{ compact(max) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="active"
|
||||||
|
class="bg-popover pointer-events-none absolute top-1 z-10 rounded-md border px-2 py-1.5 text-[11px] shadow-md"
|
||||||
|
:style="{
|
||||||
|
left: `${(hover! / Math.max(1, points.length - 1)) * 100}%`,
|
||||||
|
transform:
|
||||||
|
hover! / Math.max(1, points.length - 1) > 0.6
|
||||||
|
? 'translateX(-105%)'
|
||||||
|
: 'translateX(8px)',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="text-muted-foreground mb-0.5">{{ time(active.time) }}</div>
|
||||||
|
<div class="flex items-center gap-2 tabular-nums">
|
||||||
|
<span class="bg-tcp size-2 rounded-full" />{{ t('dashboard.tcp') }}
|
||||||
|
<span class="ml-auto font-medium">{{ active.tcp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 tabular-nums">
|
||||||
|
<span class="bg-udp size-2 rounded-full" />{{ t('dashboard.udp') }}
|
||||||
|
<span class="ml-auto font-medium">{{ active.udp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 tabular-nums">
|
||||||
|
<span class="bg-block size-2 rounded-full" />{{ t('dashboard.blocked') }}
|
||||||
|
<span class="ml-auto font-medium">{{ active.blocked }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!hasData"
|
||||||
|
class="text-muted-foreground absolute inset-0 flex items-center justify-center text-xs"
|
||||||
|
>
|
||||||
|
{{ t('dashboard.noTraffic') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1">
|
||||||
|
<div v-for="item in legend" :key="item.label" class="flex items-center gap-1.5 text-xs">
|
||||||
|
<span :class="['size-2 rounded-full', item.color]" />
|
||||||
|
<span class="text-muted-foreground">{{ item.label }}</span>
|
||||||
|
<span class="font-medium tabular-nums">{{ compact(item.value) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Trash2 } from 'lucide-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import GeoPicker from '@/components/rule/GeoPicker.vue'
|
||||||
|
import { Button, Input, Select, TagsInput } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { explainWildcard } from '@/lib/rule/compile'
|
||||||
|
import {
|
||||||
|
FIELDS,
|
||||||
|
FIELD_MAP,
|
||||||
|
PROTO_VALUES,
|
||||||
|
defaultOperator,
|
||||||
|
operatorsFor,
|
||||||
|
type OperatorId,
|
||||||
|
} from '@/lib/rule/fields'
|
||||||
|
import type { Condition } from '@/lib/rule/types'
|
||||||
|
import { validateValue } from '@/lib/rule/validate'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
condition: Condition
|
||||||
|
/** Analyzer names offered by the "protocol detected" field. */
|
||||||
|
analyzers: string[]
|
||||||
|
removable: boolean
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
update: [condition: Condition]
|
||||||
|
remove: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const field = computed(() => FIELD_MAP[props.condition.field] ?? FIELDS[0])
|
||||||
|
|
||||||
|
/** Field keys contain dots, which the translation lookup uses for nesting. */
|
||||||
|
const labelKey = (key: string) => `rules.field.${key.replace(/\./g, '_')}`
|
||||||
|
|
||||||
|
const fieldOptions = computed(() =>
|
||||||
|
FIELDS.map((f) => ({
|
||||||
|
value: f.key,
|
||||||
|
label: `${t(`rules.group.${f.group}`)} · ${t(labelKey(f.key))}`,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const operatorOptions = computed(() =>
|
||||||
|
operatorsFor(field.value).map((op) => ({ value: op, label: t(`rules.op.${op}`) })),
|
||||||
|
)
|
||||||
|
|
||||||
|
function patch(changes: Partial<Condition>) {
|
||||||
|
emit('update', { ...props.condition, ...changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeField(key: string) {
|
||||||
|
const next = FIELD_MAP[key]
|
||||||
|
if (!next) return
|
||||||
|
const keepOperator = operatorsFor(next).includes(props.condition.op)
|
||||||
|
patch({
|
||||||
|
field: key,
|
||||||
|
op: keepOperator ? props.condition.op : defaultOperator(next.type),
|
||||||
|
values: keepOperator && next.type === field.value.type ? props.condition.values : [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeOperator(op: string) {
|
||||||
|
patch({ op: op as OperatorId, values: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleValue(value: string) {
|
||||||
|
const values = props.condition.values.includes(value)
|
||||||
|
? props.condition.values.filter((v) => v !== value)
|
||||||
|
: [...props.condition.values, value]
|
||||||
|
patch({ values })
|
||||||
|
}
|
||||||
|
|
||||||
|
const validator = (value: string) => validateValue(field.value.type, props.condition.op, value)
|
||||||
|
|
||||||
|
const placeholder = computed(() => {
|
||||||
|
switch (props.condition.op) {
|
||||||
|
case 'cidr':
|
||||||
|
return '10.0.0.0/8, 2001:db8::/32'
|
||||||
|
case 'wildcard':
|
||||||
|
return '*.example.com'
|
||||||
|
case 'regex':
|
||||||
|
return '^/api/v\\d+/'
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch (field.value.type) {
|
||||||
|
case 'ip':
|
||||||
|
return '1.2.3.4'
|
||||||
|
case 'port':
|
||||||
|
return '443'
|
||||||
|
case 'domain':
|
||||||
|
return 'example.com'
|
||||||
|
default:
|
||||||
|
return t('rules.valuePlaceholder')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Human readable explanation of each wildcard pattern. */
|
||||||
|
const wildcardHints = computed(() => {
|
||||||
|
if (props.condition.op !== 'wildcard') return []
|
||||||
|
return props.condition.values
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 4)
|
||||||
|
.map((value) => {
|
||||||
|
const info = explainWildcard(value)
|
||||||
|
return `${value} → ${t(`rules.wildcard.${info.kind}`, { value: info.value })}`
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-muted/40 flex flex-col gap-2 rounded-lg border p-2.5">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Select
|
||||||
|
:model-value="props.condition.field"
|
||||||
|
:options="fieldOptions"
|
||||||
|
:aria-label="t('rules.builder.field')"
|
||||||
|
class="h-8 min-w-0 flex-1 basis-45 text-xs sm:basis-56"
|
||||||
|
@update:model-value="changeField"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
:model-value="props.condition.op"
|
||||||
|
:options="operatorOptions"
|
||||||
|
:aria-label="t('rules.builder.operator')"
|
||||||
|
class="h-8 min-w-0 flex-1 basis-32 text-xs sm:basis-40 sm:flex-none"
|
||||||
|
@update:model-value="changeOperator"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'h-8 shrink-0 rounded-md border px-2 text-xs font-medium transition-colors',
|
||||||
|
condition.negate
|
||||||
|
? 'border-block/40 bg-block/10 text-block'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
:aria-pressed="props.condition.negate"
|
||||||
|
@click="patch({ negate: !props.condition.negate })"
|
||||||
|
>
|
||||||
|
{{ t('rules.builder.not') }}
|
||||||
|
</button>
|
||||||
|
<Button
|
||||||
|
v-if="props.removable"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-muted-foreground hover:text-destructive shrink-0"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="emit('remove')"
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protocol: fixed choices -->
|
||||||
|
<div v-if="field.type === 'proto'" class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="value in PROTO_VALUES"
|
||||||
|
:key="value"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-md border px-2.5 py-1 font-mono text-xs uppercase transition-colors',
|
||||||
|
props.condition.values.includes(value)
|
||||||
|
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleValue(value)"
|
||||||
|
>
|
||||||
|
{{ value }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Protocol detection: analyzer chips -->
|
||||||
|
<div v-else-if="field.type === 'analyzer'" class="flex flex-wrap gap-1.5">
|
||||||
|
<button
|
||||||
|
v-for="name in props.analyzers"
|
||||||
|
:key="name"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-md border px-2.5 py-1 font-mono text-xs transition-colors',
|
||||||
|
props.condition.values.includes(name)
|
||||||
|
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||||
|
: 'text-muted-foreground hover:bg-accent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="toggleValue(name)"
|
||||||
|
>
|
||||||
|
{{ name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port range -->
|
||||||
|
<div v-else-if="condition.op === 'range'" class="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
:model-value="props.condition.values[0] ?? ''"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="1024"
|
||||||
|
class="h-8 w-28 text-xs"
|
||||||
|
@update:model-value="patch({ values: [$event, props.condition.values[1] ?? ''] })"
|
||||||
|
/>
|
||||||
|
<span class="text-muted-foreground text-xs">–</span>
|
||||||
|
<Input
|
||||||
|
:model-value="props.condition.values[1] ?? ''"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="65535"
|
||||||
|
class="h-8 w-28 text-xs"
|
||||||
|
@update:model-value="patch({ values: [props.condition.values[0] ?? '', $event] })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GeoIP / GeoSite pickers -->
|
||||||
|
<GeoPicker
|
||||||
|
v-else-if="props.condition.op === 'geoip' || props.condition.op === 'geosite'"
|
||||||
|
:model-value="props.condition.values"
|
||||||
|
:kind="props.condition.op"
|
||||||
|
@update:model-value="patch({ values: $event })"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Everything else: a list of values -->
|
||||||
|
<template v-else>
|
||||||
|
<TagsInput
|
||||||
|
:model-value="props.condition.values"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:validate="validator"
|
||||||
|
@update:model-value="patch({ values: $event })"
|
||||||
|
/>
|
||||||
|
<p v-for="hint in wildcardHints" :key="hint" class="text-muted-foreground font-mono text-[11px]">
|
||||||
|
{{ hint }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, ChevronsUpDown, Search, X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
ComboboxAnchor,
|
||||||
|
ComboboxContent,
|
||||||
|
ComboboxEmpty,
|
||||||
|
ComboboxInput,
|
||||||
|
ComboboxItem,
|
||||||
|
ComboboxPortal,
|
||||||
|
ComboboxRoot,
|
||||||
|
ComboboxTrigger,
|
||||||
|
ComboboxViewport,
|
||||||
|
} from 'reka-ui'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { TagsInput } from '@/components/ui'
|
||||||
|
import { useGeo } from '@/composables/useGeo'
|
||||||
|
import { t, useI18n } from '@/i18n'
|
||||||
|
import { flagOf, regionName } from '@/lib/geo'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multi-select for geoip() country codes and geosite() categories, backed by
|
||||||
|
* the databases the engine actually loaded. Falls back to a plain chip input
|
||||||
|
* when the databases are not available.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ modelValue: string[]; kind: 'geoip' | 'geosite' }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const { lang } = useI18n()
|
||||||
|
const { geo, error, loading, load } = useGeo()
|
||||||
|
const search = ref('')
|
||||||
|
|
||||||
|
interface Option {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
hint: string
|
||||||
|
flag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = computed<Option[]>(() => {
|
||||||
|
const entries = (props.kind === 'geoip' ? geo.value?.ip : geo.value?.site) ?? []
|
||||||
|
const out: Option[] = []
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (props.kind === 'geoip') {
|
||||||
|
const name = regionName(entry.code, lang.value)
|
||||||
|
out.push({
|
||||||
|
value: entry.code,
|
||||||
|
label: name ?? entry.code,
|
||||||
|
hint: `${entry.code.toUpperCase()} · ${entry.count}`,
|
||||||
|
flag: flagOf(entry.code),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
out.push({
|
||||||
|
value: entry.code,
|
||||||
|
label: entry.code,
|
||||||
|
hint: String(entry.count),
|
||||||
|
flag: '',
|
||||||
|
})
|
||||||
|
for (const attr of entry.attributes ?? []) {
|
||||||
|
out.push({
|
||||||
|
value: `${entry.code}@${attr}`,
|
||||||
|
label: `${entry.code}@${attr}`,
|
||||||
|
hint: t('rules.geo.attribute'),
|
||||||
|
flag: '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
const q = search.value.trim().toLowerCase()
|
||||||
|
const list = q
|
||||||
|
? options.value.filter((o) => o.value.includes(q) || o.label.toLowerCase().includes(q))
|
||||||
|
: options.value
|
||||||
|
return list.slice(0, 300)
|
||||||
|
})
|
||||||
|
|
||||||
|
const unavailable = computed(
|
||||||
|
() => !loading.value && options.value.length === 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
const errorMessage = computed(() =>
|
||||||
|
props.kind === 'geoip' ? error.value.ip : error.value.site,
|
||||||
|
)
|
||||||
|
|
||||||
|
function remove(value: string) {
|
||||||
|
emit(
|
||||||
|
'update:modelValue',
|
||||||
|
props.modelValue.filter((v) => v !== value),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function labelOf(value: string): string {
|
||||||
|
const option = options.value.find((o) => o.value === value)
|
||||||
|
if (option) return `${option.flag} ${option.label}`.trim()
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
void load()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<template v-if="unavailable">
|
||||||
|
<TagsInput
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:placeholder="props.kind === 'geoip' ? 'cn, us, private' : 'category-ads-all'"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
/>
|
||||||
|
<p class="text-muted-foreground text-xs">
|
||||||
|
{{ t('rules.geo.unavailable') }}
|
||||||
|
<span v-if="errorMessage" class="font-mono">({{ errorMessage }})</span>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div v-if="props.modelValue.length" class="flex flex-wrap gap-1.5">
|
||||||
|
<span
|
||||||
|
v-for="value in props.modelValue"
|
||||||
|
:key="value"
|
||||||
|
class="bg-secondary text-secondary-foreground inline-flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs"
|
||||||
|
>
|
||||||
|
{{ labelOf(value) }}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="opacity-60 transition-opacity hover:opacity-100"
|
||||||
|
:aria-label="t('common.delete')"
|
||||||
|
@click="remove(value)"
|
||||||
|
>
|
||||||
|
<X class="size-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="loading" class="text-muted-foreground text-xs">{{ t('rules.geo.loading') }}</p>
|
||||||
|
|
||||||
|
<ComboboxRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
multiple
|
||||||
|
ignore-filter
|
||||||
|
:reset-search-term-on-blur="false"
|
||||||
|
@update:model-value="emit('update:modelValue', ($event as unknown as string[]) ?? [])"
|
||||||
|
>
|
||||||
|
<ComboboxAnchor
|
||||||
|
class="border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex h-9 w-full items-center gap-2 rounded-md border px-3 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]"
|
||||||
|
>
|
||||||
|
<Search class="text-muted-foreground size-4 shrink-0" />
|
||||||
|
<ComboboxInput
|
||||||
|
v-model="search"
|
||||||
|
:placeholder="
|
||||||
|
props.kind === 'geoip' ? t('rules.geo.searchCountry') : t('rules.geo.searchSite')
|
||||||
|
"
|
||||||
|
class="placeholder:text-muted-foreground w-full bg-transparent text-sm outline-none"
|
||||||
|
/>
|
||||||
|
<ComboboxTrigger class="text-muted-foreground shrink-0">
|
||||||
|
<ChevronsUpDown class="size-4" />
|
||||||
|
</ComboboxTrigger>
|
||||||
|
</ComboboxAnchor>
|
||||||
|
|
||||||
|
<ComboboxPortal>
|
||||||
|
<ComboboxContent
|
||||||
|
position="popper"
|
||||||
|
:side-offset="4"
|
||||||
|
class="bg-popover text-popover-foreground animate-fade-in z-50 max-h-72 w-[var(--reka-combobox-trigger-width)] overflow-hidden rounded-md border shadow-md"
|
||||||
|
>
|
||||||
|
<ComboboxViewport class="max-h-72 overflow-y-auto p-1">
|
||||||
|
<ComboboxEmpty class="text-muted-foreground px-2 py-4 text-center text-xs">
|
||||||
|
{{ t('common.empty') }}
|
||||||
|
</ComboboxEmpty>
|
||||||
|
<ComboboxItem
|
||||||
|
v-for="option in filtered"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<span v-if="option.flag" class="shrink-0">{{ option.flag }}</span>
|
||||||
|
<span class="truncate">{{ option.label }}</span>
|
||||||
|
<span class="text-muted-foreground ml-auto shrink-0 text-[11px] tabular-nums">
|
||||||
|
{{ option.hint }}
|
||||||
|
</span>
|
||||||
|
<Check
|
||||||
|
v-if="props.modelValue.includes(option.value)"
|
||||||
|
class="absolute right-2 size-4"
|
||||||
|
/>
|
||||||
|
</ComboboxItem>
|
||||||
|
</ComboboxViewport>
|
||||||
|
</ComboboxContent>
|
||||||
|
</ComboboxPortal>
|
||||||
|
</ComboboxRoot>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Plus } from 'lucide-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import ConditionRow from '@/components/rule/ConditionRow.vue'
|
||||||
|
import { Button } from '@/components/ui'
|
||||||
|
import { t } from '@/i18n'
|
||||||
|
import { compileRule } from '@/lib/rule/compile'
|
||||||
|
import { newCondition, type Condition, type RuleBuilderState } from '@/lib/rule/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ modelValue: RuleBuilderState; analyzers: string[] }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: RuleBuilderState] }>()
|
||||||
|
|
||||||
|
const expression = computed(() => compileRule(props.modelValue))
|
||||||
|
|
||||||
|
function patch(changes: Partial<RuleBuilderState>) {
|
||||||
|
emit('update:modelValue', { ...props.modelValue, ...changes })
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCondition(index: number, condition: Condition) {
|
||||||
|
const conditions = [...props.modelValue.conditions]
|
||||||
|
conditions[index] = condition
|
||||||
|
patch({ conditions })
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCondition(index: number) {
|
||||||
|
patch({ conditions: props.modelValue.conditions.filter((_, i) => i !== index) })
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCondition() {
|
||||||
|
patch({ conditions: [...props.modelValue.conditions, newCondition()] })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span class="text-muted-foreground">{{ t('rules.builder.match') }}</span>
|
||||||
|
<div class="bg-muted inline-flex rounded-md p-0.5">
|
||||||
|
<button
|
||||||
|
v-for="mode in ['and', 'or'] as const"
|
||||||
|
:key="mode"
|
||||||
|
type="button"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'rounded-[5px] px-2.5 py-1 text-xs font-medium transition-colors',
|
||||||
|
props.modelValue.mode === mode
|
||||||
|
? 'bg-background text-foreground shadow-sm'
|
||||||
|
: 'text-muted-foreground hover:text-foreground',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@click="patch({ mode })"
|
||||||
|
>
|
||||||
|
{{ t(`rules.builder.${mode}`) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<span class="text-muted-foreground">{{ t('rules.builder.matchSuffix') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<template v-for="(condition, index) in props.modelValue.conditions" :key="condition.id">
|
||||||
|
<div v-if="index > 0" class="flex items-center gap-2">
|
||||||
|
<span class="bg-border h-px flex-1" />
|
||||||
|
<span class="text-muted-foreground text-[11px] font-medium uppercase">
|
||||||
|
{{ t(`rules.builder.${props.modelValue.mode}`) }}
|
||||||
|
</span>
|
||||||
|
<span class="bg-border h-px flex-1" />
|
||||||
|
</div>
|
||||||
|
<ConditionRow
|
||||||
|
:condition="condition"
|
||||||
|
:analyzers="props.analyzers"
|
||||||
|
:removable="props.modelValue.conditions.length > 1"
|
||||||
|
@update="updateCondition(index, $event)"
|
||||||
|
@remove="removeCondition(index)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button variant="outline" size="sm" class="w-full" @click="addCondition">
|
||||||
|
<Plus class="size-3.5" />
|
||||||
|
{{ t('rules.builder.addCondition') }}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-muted-foreground text-xs font-medium">
|
||||||
|
{{ t('rules.builder.preview') }}
|
||||||
|
</span>
|
||||||
|
<code
|
||||||
|
class="bg-muted/60 text-muted-foreground block rounded-md px-2.5 py-2 font-mono text-[11px] leading-relaxed break-all"
|
||||||
|
>
|
||||||
|
{{ expression || t('rules.builder.empty') }}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export const badgeVariants = cva(
|
||||||
|
'inline-flex items-center justify-center gap-1 rounded-md border px-1.5 py-0.5 text-[11px] font-medium whitespace-nowrap tabular-nums',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'border-transparent bg-primary text-primary-foreground',
|
||||||
|
secondary: 'border-transparent bg-secondary text-secondary-foreground',
|
||||||
|
outline: 'text-foreground',
|
||||||
|
muted: 'border-transparent bg-muted text-muted-foreground',
|
||||||
|
allow: 'border-allow/25 bg-allow/12 text-allow',
|
||||||
|
block: 'border-block/25 bg-block/12 text-block',
|
||||||
|
drop: 'border-drop/25 bg-drop/12 text-drop',
|
||||||
|
modify: 'border-modify/25 bg-modify/12 text-modify',
|
||||||
|
log: 'border-log/25 bg-log/12 text-log',
|
||||||
|
tcp: 'border-tcp/25 bg-tcp/12 text-tcp',
|
||||||
|
udp: 'border-udp/25 bg-udp/12 text-udp',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: { variant: 'default' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type BadgeVariants = VariantProps<typeof badgeVariants>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ variant?: BadgeVariants['variant']; class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="cn(badgeVariants({ variant: props.variant }), props.class)">
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
export const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow,background-color] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:border-ring active:scale-[0.98] select-none",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||||
|
destructive:
|
||||||
|
'bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/30',
|
||||||
|
outline:
|
||||||
|
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground',
|
||||||
|
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||||
|
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||||
|
link: 'text-primary underline-offset-4 hover:underline',
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||||
|
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5 text-[13px]',
|
||||||
|
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||||
|
icon: 'size-9',
|
||||||
|
'icon-sm': 'size-8 rounded-md',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
size: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
export type ButtonVariants = VariantProps<typeof buttonVariants>
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Primitive, type PrimitiveProps } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<
|
||||||
|
PrimitiveProps & {
|
||||||
|
variant?: ButtonVariants['variant']
|
||||||
|
size?: ButtonVariants['size']
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
>(),
|
||||||
|
{ as: 'button' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Primitive
|
||||||
|
:as="props.as"
|
||||||
|
:as-child="props.asChild"
|
||||||
|
:class="cn(buttonVariants({ variant: props.variant, size: props.size }), props.class)"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Primitive>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-card text-card-foreground rounded-xl border shadow-sm transition-colors',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('px-4 pb-4 sm:px-5 sm:pb-5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<p :class="cn('text-muted-foreground text-xs sm:text-sm', props.class)">
|
||||||
|
<slot />
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('flex flex-col gap-1 px-4 pt-4 pb-3 sm:px-5 sm:pt-5', props.class)">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h3 :class="cn('text-sm leading-none font-semibold tracking-tight sm:text-base', props.class)">
|
||||||
|
<slot />
|
||||||
|
</h3>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; modelValue?: string | number }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:value="props.modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'border-input bg-background flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||||
|
'placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Label } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; for?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Label
|
||||||
|
:for="props.for"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-2 text-sm leading-none font-medium select-none',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</Label>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogPortal,
|
||||||
|
DialogRoot,
|
||||||
|
DialogTitle,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A dialog that turns into a bottom drawer on small screens, which is much
|
||||||
|
* easier to reach with a thumb than a centered modal.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
class?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:open': [value: boolean] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<DialogRoot :open="props.open" @update:open="emit('update:open', $event)">
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay
|
||||||
|
class="animate-fade-in fixed inset-0 z-50 bg-black/60 backdrop-blur-[2px]"
|
||||||
|
/>
|
||||||
|
<DialogContent
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-background fixed z-50 flex flex-col gap-4 border shadow-lg outline-none',
|
||||||
|
// Mobile: bottom drawer
|
||||||
|
'animate-slide-up inset-x-0 bottom-0 max-h-[92vh] rounded-t-2xl border-b-0 p-4 pb-[max(1rem,env(safe-area-inset-bottom))]',
|
||||||
|
// Desktop: centered dialog
|
||||||
|
'sm:top-1/2 sm:bottom-auto sm:left-1/2 sm:max-h-[85vh] sm:w-full sm:max-w-lg sm:-translate-x-1/2 sm:-translate-y-1/2 sm:rounded-xl sm:border-b sm:p-6',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="bg-muted-foreground/25 mx-auto -mt-1 mb-1 h-1 w-10 shrink-0 rounded-full sm:hidden"
|
||||||
|
/>
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<DialogTitle class="text-base font-semibold">{{ props.title }}</DialogTitle>
|
||||||
|
<DialogDescription v-if="props.description" class="text-muted-foreground text-sm">
|
||||||
|
{{ props.description }}
|
||||||
|
</DialogDescription>
|
||||||
|
</div>
|
||||||
|
<DialogClose
|
||||||
|
class="text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:ring-ring/40 -mt-1 -mr-1 rounded-md p-1.5 transition-colors outline-none focus-visible:ring-[3px]"
|
||||||
|
>
|
||||||
|
<X class="size-4" />
|
||||||
|
</DialogClose>
|
||||||
|
</div>
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.footer" class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</DialogPortal>
|
||||||
|
</DialogRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
SelectPortal,
|
||||||
|
SelectRoot,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectViewport,
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
|
import type { SelectOption } from '@/lib/types'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
options: SelectOption[]
|
||||||
|
placeholder?: string
|
||||||
|
ariaLabel?: string
|
||||||
|
class?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SelectRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
@update:model-value="emit('update:modelValue', String($event))"
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
:aria-label="props.ariaLabel"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'border-input bg-background flex h-9 w-full items-center justify-between gap-2 rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] outline-none',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||||
|
'data-[placeholder]:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<SelectValue :placeholder="props.placeholder ?? ''" class="truncate" />
|
||||||
|
<ChevronDown class="size-4 shrink-0 opacity-50" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
position="popper"
|
||||||
|
:side-offset="4"
|
||||||
|
class="bg-popover text-popover-foreground animate-fade-in relative z-50 max-h-72 min-w-[var(--reka-select-trigger-width)] overflow-hidden rounded-md border shadow-md"
|
||||||
|
>
|
||||||
|
<SelectViewport class="p-1">
|
||||||
|
<SelectItem
|
||||||
|
v-for="option in props.options"
|
||||||
|
:key="option.value"
|
||||||
|
:value="option.value"
|
||||||
|
class="focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ option.label }}</SelectItemText>
|
||||||
|
<SelectItemIndicator class="absolute right-2 flex items-center">
|
||||||
|
<Check class="size-4" />
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectViewport>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</SelectRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{ class?: string; orientation?: 'horizontal' | 'vertical' }>(),
|
||||||
|
{ orientation: 'horizontal' },
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
role="separator"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-border shrink-0',
|
||||||
|
props.orientation === 'vertical' ? 'h-full w-px' : 'h-px w-full',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="cn('bg-muted animate-pulse rounded-md', props.class)" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; modelValue?: boolean; id?: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<SwitchRoot
|
||||||
|
:id="props.id"
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<SwitchThumb
|
||||||
|
class="bg-background pointer-events-none block size-4 rounded-full shadow-sm ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0.5"
|
||||||
|
/>
|
||||||
|
</SwitchRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TabsRoot } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:class="cn('flex flex-col gap-3', props.class)"
|
||||||
|
@update:model-value="emit('update:modelValue', String($event))"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsRoot>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TabsContent } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; value: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsContent :value="props.value" :class="cn('outline-none', props.class)">
|
||||||
|
<slot />
|
||||||
|
</TabsContent>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TabsList } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsList
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-1',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsList>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { TabsTrigger } from 'reka-ui'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; value: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<TabsTrigger
|
||||||
|
:value="props.value"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'inline-flex flex-1 items-center justify-center gap-1.5 rounded-md px-3 py-1 text-sm font-medium whitespace-nowrap transition-all outline-none',
|
||||||
|
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',
|
||||||
|
'focus-visible:ring-ring/40 focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50',
|
||||||
|
'[&_svg]:size-3.5',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TabsTrigger>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { X } from 'lucide-vue-next'
|
||||||
|
import { TagsInputInput, TagsInputItem, TagsInputItemDelete, TagsInputItemText, TagsInputRoot } from 'reka-ui'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
/** Chip style input for a list of values, with per-value validation. */
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
placeholder?: string
|
||||||
|
/** Returns an error message for an invalid value, or "" when it is fine. */
|
||||||
|
validate?: (value: string) => string
|
||||||
|
class?: string
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string[]] }>()
|
||||||
|
|
||||||
|
const invalid = computed(() => {
|
||||||
|
if (!props.validate) return new Set<string>()
|
||||||
|
return new Set(props.modelValue.filter((v) => props.validate?.(v)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const errors = computed(() => {
|
||||||
|
if (!props.validate) return [] as string[]
|
||||||
|
const messages = new Set<string>()
|
||||||
|
for (const value of props.modelValue) {
|
||||||
|
const message = props.validate(value)
|
||||||
|
if (message) messages.add(message)
|
||||||
|
}
|
||||||
|
return [...messages]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-col gap-1.5">
|
||||||
|
<TagsInputRoot
|
||||||
|
:model-value="props.modelValue"
|
||||||
|
:delimiter="','"
|
||||||
|
:add-on-paste="true"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'border-input bg-background focus-within:border-ring focus-within:ring-ring/40 flex min-h-9 w-full flex-wrap items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-xs transition-[color,box-shadow] focus-within:ring-[3px]',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@update:model-value="emit('update:modelValue', $event as string[])"
|
||||||
|
>
|
||||||
|
<TagsInputItem
|
||||||
|
v-for="value in props.modelValue"
|
||||||
|
:key="value"
|
||||||
|
:value="value"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'flex items-center gap-1 rounded-md border px-1.5 py-0.5 font-mono text-xs',
|
||||||
|
invalid.has(value)
|
||||||
|
? 'border-destructive/50 bg-destructive/10 text-destructive'
|
||||||
|
: 'bg-secondary text-secondary-foreground border-transparent',
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<TagsInputItemText />
|
||||||
|
<TagsInputItemDelete class="hover:text-foreground opacity-60 transition-opacity hover:opacity-100">
|
||||||
|
<X class="size-3" />
|
||||||
|
</TagsInputItemDelete>
|
||||||
|
</TagsInputItem>
|
||||||
|
<TagsInputInput
|
||||||
|
:placeholder="props.modelValue.length ? '' : props.placeholder"
|
||||||
|
class="placeholder:text-muted-foreground min-w-24 flex-1 bg-transparent px-1 text-sm outline-none"
|
||||||
|
/>
|
||||||
|
</TagsInputRoot>
|
||||||
|
<p v-for="message in errors" :key="message" class="text-destructive text-xs">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ class?: string; modelValue?: string }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
:value="props.modelValue"
|
||||||
|
:class="
|
||||||
|
cn(
|
||||||
|
'border-input bg-background flex min-h-20 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none sm:text-sm',
|
||||||
|
'placeholder:text-muted-foreground field-sizing-content',
|
||||||
|
'focus-visible:border-ring focus-visible:ring-ring/40 focus-visible:ring-[3px]',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
props.class,
|
||||||
|
)
|
||||||
|
"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
export { default as Badge, badgeVariants } from './Badge.vue'
|
||||||
|
export { default as Button, buttonVariants } from './Button.vue'
|
||||||
|
export { default as Card } from './Card.vue'
|
||||||
|
export { default as CardContent } from './CardContent.vue'
|
||||||
|
export { default as CardDescription } from './CardDescription.vue'
|
||||||
|
export { default as CardHeader } from './CardHeader.vue'
|
||||||
|
export { default as CardTitle } from './CardTitle.vue'
|
||||||
|
export { default as Input } from './Input.vue'
|
||||||
|
export { default as Label } from './Label.vue'
|
||||||
|
export { default as Modal } from './Modal.vue'
|
||||||
|
export { default as Select } from './Select.vue'
|
||||||
|
export { default as Separator } from './Separator.vue'
|
||||||
|
export { default as Skeleton } from './Skeleton.vue'
|
||||||
|
export { default as Switch } from './Switch.vue'
|
||||||
|
export { default as Tabs } from './Tabs.vue'
|
||||||
|
export { default as TagsInput } from './TagsInput.vue'
|
||||||
|
export { default as TabsContent } from './TabsContent.vue'
|
||||||
|
export { default as TabsList } from './TabsList.vue'
|
||||||
|
export { default as TabsTrigger } from './TabsTrigger.vue'
|
||||||
|
export { default as Textarea } from './Textarea.vue'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { api } from '@/lib/api'
|
||||||
|
import type { GeoData } from '@/lib/types'
|
||||||
|
|
||||||
|
const geo = ref<GeoData | null>(null)
|
||||||
|
const error = ref<{ ip: string; site: string }>({ ip: '', site: '' })
|
||||||
|
const loading = ref(false)
|
||||||
|
let pending: Promise<void> | null = null
|
||||||
|
|
||||||
|
/** Loads the geo database listing once and shares it across components. */
|
||||||
|
async function load(force = false): Promise<void> {
|
||||||
|
if (geo.value && !force) return
|
||||||
|
if (pending) return pending
|
||||||
|
loading.value = true
|
||||||
|
pending = (async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.geo(force)
|
||||||
|
geo.value = data
|
||||||
|
error.value = { ip: data.ipError ?? '', site: data.siteError ?? '' }
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : String(e)
|
||||||
|
error.value = { ip: message, site: message }
|
||||||
|
geo.value = { ip: [], site: [] }
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
pending = null
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGeo() {
|
||||||
|
return { geo, error, loading, load }
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { computed, ref, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { api, auth, liveUrl } from '@/lib/api'
|
||||||
|
import type { LiveEvent, Metrics } from '@/lib/types'
|
||||||
|
|
||||||
|
const MAX_EVENTS = 500
|
||||||
|
|
||||||
|
export type LiveStatus = 'closed' | 'connecting' | 'open'
|
||||||
|
|
||||||
|
const metrics = ref<Metrics | null>(null)
|
||||||
|
const events = shallowRef<LiveEvent[]>([])
|
||||||
|
const status = ref<LiveStatus>('closed')
|
||||||
|
|
||||||
|
let source: EventSource | null = null
|
||||||
|
let retryTimer: number | undefined
|
||||||
|
let retryDelay = 1000
|
||||||
|
|
||||||
|
function pushEvent(event: LiveEvent) {
|
||||||
|
const next = [event, ...events.value]
|
||||||
|
if (next.length > MAX_EVENTS) next.length = MAX_EVENTS
|
||||||
|
events.value = next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
try {
|
||||||
|
const [m, e] = await Promise.all([api.metrics(), api.events(MAX_EVENTS)])
|
||||||
|
metrics.value = m
|
||||||
|
if (events.value.length === 0) events.value = e.events ?? []
|
||||||
|
} catch {
|
||||||
|
// The SSE connection will report the problem, no need to duplicate it.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (source || !auth.authenticated) return
|
||||||
|
status.value = 'connecting'
|
||||||
|
const es = new EventSource(liveUrl)
|
||||||
|
source = es
|
||||||
|
|
||||||
|
es.addEventListener('open', () => {
|
||||||
|
status.value = 'open'
|
||||||
|
retryDelay = 1000
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('metrics', (ev) => {
|
||||||
|
try {
|
||||||
|
metrics.value = JSON.parse((ev as MessageEvent<string>).data) as Metrics
|
||||||
|
status.value = 'open'
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed frame */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('event', (ev) => {
|
||||||
|
try {
|
||||||
|
pushEvent(JSON.parse((ev as MessageEvent<string>).data) as LiveEvent)
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed frame */
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
es.addEventListener('error', () => {
|
||||||
|
es.close()
|
||||||
|
if (source === es) source = null
|
||||||
|
status.value = 'closed'
|
||||||
|
// The session may have expired; a plain fetch tells us for sure.
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
|
retryTimer = window.setTimeout(() => {
|
||||||
|
if (auth.authenticated) connect()
|
||||||
|
}, retryDelay)
|
||||||
|
retryDelay = Math.min(retryDelay * 2, 15000)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLive() {
|
||||||
|
if (!auth.authenticated) return
|
||||||
|
void seed()
|
||||||
|
connect()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopLive() {
|
||||||
|
window.clearTimeout(retryTimer)
|
||||||
|
source?.close()
|
||||||
|
source = null
|
||||||
|
status.value = 'closed'
|
||||||
|
metrics.value = null
|
||||||
|
events.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearEvents() {
|
||||||
|
events.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLive() {
|
||||||
|
return {
|
||||||
|
metrics,
|
||||||
|
events,
|
||||||
|
status,
|
||||||
|
connected: computed(() => status.value === 'open'),
|
||||||
|
startLive,
|
||||||
|
stopLive,
|
||||||
|
clearEvents,
|
||||||
|
refresh: seed,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
export type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
const THEME_KEY = 'opengfw.theme'
|
||||||
|
const stored = localStorage.getItem(THEME_KEY)
|
||||||
|
const theme = ref<Theme>(stored === 'light' || stored === 'dark' ? stored : 'system')
|
||||||
|
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||||
|
|
||||||
|
function resolve(value: Theme): boolean {
|
||||||
|
return value === 'dark' || (value === 'system' && media.matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isDark = ref(resolve(theme.value))
|
||||||
|
|
||||||
|
function apply() {
|
||||||
|
isDark.value = resolve(theme.value)
|
||||||
|
document.documentElement.classList.toggle('dark', isDark.value)
|
||||||
|
document
|
||||||
|
.querySelector('meta[name="theme-color"]')
|
||||||
|
?.setAttribute('content', isDark.value ? '#09090b' : '#ffffff')
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(theme, (value) => {
|
||||||
|
localStorage.setItem(THEME_KEY, value)
|
||||||
|
apply()
|
||||||
|
})
|
||||||
|
|
||||||
|
media.addEventListener('change', () => {
|
||||||
|
if (theme.value === 'system') apply()
|
||||||
|
})
|
||||||
|
|
||||||
|
apply()
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return {
|
||||||
|
theme,
|
||||||
|
isDark: computed(() => isDark.value),
|
||||||
|
setTheme: (value: Theme) => {
|
||||||
|
theme.value = value
|
||||||
|
},
|
||||||
|
toggle: () => {
|
||||||
|
theme.value = isDark.value ? 'light' : 'dark'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
export type ToastVariant = 'default' | 'success' | 'error'
|
||||||
|
|
||||||
|
export interface Toast {
|
||||||
|
id: number
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
variant: ToastVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<Toast[]>([])
|
||||||
|
let nextId = 1
|
||||||
|
|
||||||
|
function push(title: string, variant: ToastVariant, description?: string) {
|
||||||
|
const id = nextId++
|
||||||
|
toasts.value = [...toasts.value, { id, title, description, variant }]
|
||||||
|
window.setTimeout(() => dismiss(id), variant === 'error' ? 6000 : 3500)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dismiss(id: number) {
|
||||||
|
toasts.value = toasts.value.filter((t) => t.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toast = {
|
||||||
|
info: (title: string, description?: string) => push(title, 'default', description),
|
||||||
|
success: (title: string, description?: string) => push(title, 'success', description),
|
||||||
|
error: (title: string, description?: string) => push(title, 'error', description),
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return { toasts, toast, dismiss }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user