Compare commits

..

7 Commits

Author SHA1 Message Date
sagit 524ee4cd95 Merge pull request #107 from Sagit-chu/opencode/curious-harbor
fix(backup): restore backup export/import APIs and route compatibility
2026-02-13 16:36:34 +08:00
sagit c049ceaacf fix(backend): resolve backup handler build conflict after main merge 2026-02-13 08:32:00 +00:00
sagit 3424221176 Merge branch 'main' into opencode/curious-harbor 2026-02-13 16:21:56 +08:00
sagit 5a9715eb26 fix(backup): restore backup export/import APIs and route compatibility 2026-02-13 08:17:34 +00:00
sagit 1b79213aed Merge pull request #106 from Sagit-chu/opencode/init-deep-agents
docs: update AGENTS.md hierarchy with new subdirectory docs
2026-02-13 16:11:51 +08:00
sagit c0d71125f4 Merge branch 'main' into opencode/init-deep-agents 2026-02-13 15:53:38 +08:00
sagit f01c0481cd docs: update AGENTS.md hierarchy with new subdirectory docs
- Update root AGENTS.md with expanded anti-patterns and notes
- Add handler/AGENTS.md for high-complexity backend handlers
- Add connector/AGENTS.md for GOST connector protocols
- Add socket/AGENTS.md for GOST socket utilities
2026-02-13 07:52:17 +00:00
9 changed files with 298 additions and 9 deletions
+11 -4
View File
@@ -1,8 +1,8 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Mon Feb 02 2026
**Commit:** 7ca01ab
**Branch:** beta
**Generated:** Fri Feb 13 2026
**Commit:** 3799729
**Branch:** (detached)
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
@@ -47,7 +47,11 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
## ANTI-PATTERNS (THIS PROJECT)
- Do not edit generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
- **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
- **DO NOT USE** ORM in backend - uses raw SQL with `database/sql`.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
## COMMANDS
```bash
@@ -68,3 +72,6 @@ docker compose -f docker-compose-v6.yml up -d
## NOTES
- LSP servers are not installed in this environment (gopls/jdtls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
- CI uses UPX compression on Go binaries before release.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
@@ -0,0 +1,43 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
**Stack:** Go 1.23, net/http, raw SQL (no ORM).
## STRUCTURE
```
handler/
├── handler.go # Main Handler struct, login/captcha, job scheduling
├── control_plane.go # Node control plane API (add/delete/list)
├── federation.go # Federation/cluster sync API
├── flow_policy.go # Traffic policy API
├── jobs.go # Background job management (sync, cleanup)
├── mutations.go # CRUD for users, tunnels, forwards (largest: 100k+ LOC)
└── upgrade.go # System upgrade API
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **User/Tunnel CRUD** | `mutations.go` | Largest file; all create/update/delete ops |
| **Login/Captcha** | `handler.go` | Login flow, captcha verification |
| **Federation Sync** | `federation.go` | Panel-to-panel sync |
| **Traffic Policies** | `flow_policy.go` | Flow limiting, quota management |
| **Background Jobs** | `jobs.go` | Scheduled sync/cleanup tasks |
## CONVENTIONS
- Inherits from parent: raw SQL, no ORM, JWT in Authorization header.
- Large files expected (`mutations.go` >100k LOC).
- Uses `sqlite.Repository` for DB access via `repo.XXX()` methods.
## ANTI-PATTERNS
- Do NOT add ORM here - uses raw SQL throughout.
- Do NOT change handler signatures without updating router.go.
## COMMANDS
```bash
cd go-backend
go test ./internal/http/handler/...
```
+6 -3
View File
@@ -93,6 +93,12 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/config/list", h.getConfigs)
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/api/v1/api/v1/backup/restore", h.backupImport)
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
mux.HandleFunc("/api/v1/user/package", h.userPackage)
@@ -172,9 +178,6 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand))
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
mux.HandleFunc("/api/v1/backup/export", h.backupExport)
mux.HandleFunc("/api/v1/backup/import", h.backupImport)
mux.HandleFunc("/flow/test", h.flowTest)
mux.HandleFunc("/flow/config", h.flowConfig)
mux.HandleFunc("/flow/upload", h.flowUpload)
@@ -117,6 +117,14 @@ func requiresAdmin(path string) bool {
return true
}
if strings.HasPrefix(path, "/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/api/v1/backup/") {
return true
}
if strings.HasPrefix(path, "/api/v1/tunnel/") {
if strings.HasPrefix(path, "/api/v1/tunnel/user/tunnel") {
return false
@@ -2484,7 +2484,7 @@ func (r *Repository) exportUserGroups() ([]UserGroupBackup, error) {
func (r *Repository) exportPermissions() ([]PermissionBackup, error) {
rows, err := r.db.Query(`
SELECT id, user_group_id, tunnel_group_id, created_time, created_by_group
SELECT id, user_group_id, tunnel_group_id, created_time
FROM group_permission ORDER BY id ASC
`)
if err != nil {
@@ -2495,9 +2495,10 @@ func (r *Repository) exportPermissions() ([]PermissionBackup, error) {
var permissions []PermissionBackup
for rows.Next() {
var p PermissionBackup
if err := rows.Scan(&p.ID, &p.UserGroupID, &p.TunnelGroupID, &p.CreatedTime, &p.CreatedByGroup); err != nil {
if err := rows.Scan(&p.ID, &p.UserGroupID, &p.TunnelGroupID, &p.CreatedTime); err != nil {
return nil, err
}
p.CreatedByGroup = 0
// Get grants for this permission
grantRows, err := r.db.Query(`SELECT id, user_group_id, tunnel_group_id, user_tunnel_id, created_time, created_by_group FROM group_permission_grant WHERE user_group_id = ? AND tunnel_group_id = ?`, p.UserGroupID, p.TunnelGroupID)
if err != nil {
@@ -203,6 +203,160 @@ func TestSpeedLimitTunnelsRouteAlias(t *testing.T) {
})
}
func TestBackupExportImportRestoreContracts(t *testing.T) {
secret := "contract-jwt-secret"
router, repo := setupContractRouter(t, secret)
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
userToken, err := auth.GenerateToken(2, "normal_user", 1, secret)
if err != nil {
t.Fatalf("generate user token: %v", err)
}
key := "backup_contract_key"
if _, err := repo.DB().Exec(`
INSERT INTO vite_config(name, value, time)
VALUES(?, ?, ?)
ON CONFLICT(name) DO UPDATE SET value = excluded.value, time = excluded.time
`, key, "v1", time.Now().UnixMilli()); err != nil {
t.Fatalf("seed config for backup contract: %v", err)
}
t.Run("non-admin is blocked on backup export", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", nil)
req.Header.Set("Authorization", userToken)
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertCodeMsg(t, resp, 403, "权限不足,仅管理员可操作")
})
t.Run("standard and duplicate export routes both work", func(t *testing.T) {
payloadA := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
if len(payloadA.Configs) == 0 {
t.Fatalf("expected exported configs, got none")
}
if _, ok := payloadA.Configs[key]; !ok {
t.Fatalf("expected %q in exported configs", key)
}
payloadB := exportBackupPayload(t, router, "/api/v1/api/v1/backup/export", adminToken)
if len(payloadB.Configs) == 0 {
t.Fatalf("expected exported configs from duplicate-prefix route, got none")
}
})
t.Run("backup import applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v2"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal import payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/import", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode import response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected import code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := repo.GetConfigByName(key)
if err != nil {
t.Fatalf("query imported config: %v", err)
}
if cfg == nil || cfg.Value != "v2" {
t.Fatalf("expected imported config value v2, got %+v", cfg)
}
})
t.Run("backup restore alias applies exported data", func(t *testing.T) {
payload := exportBackupPayload(t, router, "/api/v1/backup/export", adminToken)
payload.Configs[key] = "v3"
raw, err := json.Marshal(backupImportPayload{Types: []string{"configs"}, backupExportPayload: payload})
if err != nil {
t.Fatalf("marshal restore payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/restore", bytes.NewReader(raw))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
var out response.R
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("decode restore response: %v", err)
}
if out.Code != 0 {
t.Fatalf("expected restore code 0, got %d (%s)", out.Code, out.Msg)
}
cfg, err := repo.GetConfigByName(key)
if err != nil {
t.Fatalf("query restored config: %v", err)
}
if cfg == nil || cfg.Value != "v3" {
t.Fatalf("expected restored config value v3, got %+v", cfg)
}
})
}
type backupExportPayload struct {
Version string `json:"version"`
ExportedAt int64 `json:"exportedAt"`
Configs map[string]string `json:"configs"`
}
type backupImportPayload struct {
Types []string `json:"types"`
backupExportPayload
}
func exportBackupPayload(t *testing.T, router http.Handler, path, token string) backupExportPayload {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(`{"types":["configs"]}`))
req.Header.Set("Authorization", token)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200 on %s, got %d", path, resp.Code)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read backup payload from %s: %v", path, err)
}
var payload backupExportPayload
if err := json.Unmarshal(body, &payload); err != nil {
t.Fatalf("decode backup payload from %s: %v", path, err)
}
if strings.TrimSpace(payload.Version) == "" {
var out response.R
if err := json.Unmarshal(body, &out); err == nil {
t.Fatalf("expected backup payload on %s, got envelope code=%d msg=%q", path, out.Code, out.Msg)
}
t.Fatalf("expected non-empty backup payload version on %s, body=%s", path, string(body))
}
if payload.Configs == nil {
t.Fatalf("expected configs map in backup payload on %s", path)
}
return payload
}
func setupContractRouter(t *testing.T, jwtSecret string) (http.Handler, *sqlite.Repository) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "contract.db")
+39
View File
@@ -0,0 +1,39 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
**Stack:** Go, GOST core.
## STRUCTURE
```
connector/
├── direct/ # Direct connection
├── forward/ # Forward proxy
├── http/ # HTTP connector
├── http2/ # HTTP/2 connector
├── relay/ # Relay protocol
├── router/ # Router connector
├── serial/ # Serial port
├── sni/ # SNI routing
├── socks/ # SOCKS4/5
├── ss/ # Shadowsocks
├── sshd/ # SSH daemon
├── tcp/ # TCP connector
├── tunnel/ # Tunnel mode
└── unix/ # Unix socket
```
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Each subdir implements `Connector` interface from GOST core.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf in `go-gost/x/internal/util/grpc/proto/`.
## COMMANDS
```bash
cd go-gost
go test ./x/connector/...
```
+30
View File
@@ -0,0 +1,30 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
## OVERVIEW
Socket utilities and wrappers for GOST forwarding.
**Stack:** Go, GOST core.
## STRUCTURE
```
socket/
├── socket.go # Core socket interface
├── udp.go # UDP socket handling
├── packet.go # Packet framing
├── packetconn.go # Packet connection wrapper
└── ... # Additional socket utilities
```
## CONVENTIONS
- Inherits from parent `go-gost/x/` conventions.
- Low-level network primitives.
## ANTI-PATTERNS
- DO NOT EDIT generated protobuf.
## COMMANDS
```bash
cd go-gost
go test ./x/socket/...
```
+4
View File
@@ -140,6 +140,10 @@ export const updateConfigs = (configMap: Record<string, string>) =>
export const updateConfig = (name: string, value: string) =>
Network.post("/config/update-single", { name, value });
export const exportBackupData = () => Network.post("/backup/export");
export const importBackupData = (data: any) => Network.post("/backup/import", data);
export const restoreBackupData = (data: any) => Network.post("/backup/restore", data);
// 验证码相关接口
export const checkCaptcha = () => Network.post("/captcha/check");
export const generateCaptcha = () => Network.post(`/captcha/generate`);