Compare commits

..

20 Commits

Author SHA1 Message Date
sagit 7808d57a79 Merge pull request #112 from Sagit-chu/opencode/proud-planet
fix(frontend): remove deep blue focus border
2026-02-14 10:16:33 +08:00
sagit 46bc4ca6e4 fix(frontend): remove deep blue focus border 2026-02-14 02:14:50 +00:00
sagit 28e66ab172 Merge pull request #111 from Sagit-chu/opencode/proud-planet
fix: apply remote entry runtime updates and input focus styling
2026-02-14 09:26:40 +08:00
sagit f19bccec4c Merge remote-tracking branch 'origin/main' into opencode/proud-planet 2026-02-14 01:25:21 +00:00
sagit e37d6cf666 fix(frontend): remove input inner shadow and improve focus visibility 2026-02-14 01:16:08 +00:00
sagit 177c2bc35f Merge pull request #110 from Sagit-chu/opencode/proud-planet
fix(backend): stabilize tls forwarding and udp ttl defaults
2026-02-13 22:42:27 +08:00
sagit 76c0978763 Merge branch 'main' into opencode/proud-planet 2026-02-13 22:40:19 +08:00
sagit fd1168d855 fix(backend): set udp ttl default for tls tunnel protocol 2026-02-13 14:22:13 +00:00
sagit 92c9590c1a fix(backend): apply entry chains for remote federation nodes
Ensure remote entry nodes receive AddChains during tunnel runtime apply while tolerating offline/timeout cases. Add focused contract coverage for online and offline remote entry behavior.
2026-02-13 13:59:17 +00:00
sagit 2afb1d275a Merge pull request #109 from Sagit-chu/opencode/playful-circuit
fix(backup): handle nullable tunnel export fields safely
2026-02-13 18:38:02 +08:00
sagit 880cd4cac5 fix(backup): handle nullable tunnel export fields safely 2026-02-13 10:34:58 +00:00
sagit a69a0f040b Merge branch 'main' into opencode/kind-planet 2026-02-13 17:48:27 +08:00
sagit cf6294a77d fix(backend): normalize strategy data and proxy ip parsing 2026-02-13 09:42:38 +00:00
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
17 changed files with 683 additions and 31 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/...
```
@@ -114,7 +114,7 @@ func (h *Handler) ensureTunnelPermission(userID int64, roleID int, tunnelID int6
func (h *Handler) getForwardRecord(forwardID int64) (*forwardRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward WHERE id = ? LIMIT 1
`, forwardID)
var fr forwardRecord
@@ -152,7 +152,7 @@ func (h *Handler) getTunnelRecord(tunnelID int64) (*tunnelRecord, error) {
func (h *Handler) listForwardsByTunnel(tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE tunnel_id = ?
ORDER BY id ASC
@@ -200,6 +200,26 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
return result, nil
}
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
row := h.repo.DB().QueryRow(`
SELECT protocol
FROM chain_tunnel
WHERE tunnel_id = ? AND chain_type = '3'
ORDER BY id ASC
LIMIT 1
`, tunnelID)
var protocol sql.NullString
if err := row.Scan(&protocol); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return false, err
}
return isTLSTunnelProtocol(protocol.String), nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
row := h.repo.DB().QueryRow(`
SELECT id, name, server_ip, server_ip_v4, server_ip_v6, status, port, tcp_listen_addr, udp_listen_addr, interface_name, is_remote, remote_url, remote_token, remote_config
@@ -346,6 +366,10 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
return err
}
serviceBase := buildForwardServiceBase(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
if err != nil {
return err
}
for _, fp := range ports {
if limiterID != nil && speed != nil {
@@ -356,7 +380,7 @@ func (h *Handler) syncForwardServices(forward *forwardRecord, method string, all
if err != nil {
return err
}
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID)
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" {
_, err = h.sendNodeCommand(node.ID, "AddService", services, true, false)
@@ -1095,7 +1119,7 @@ func isNotFoundError(err error) bool {
return strings.Contains(msg, "not found") || strings.Contains(msg, "不存在")
}
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64) []map[string]interface{} {
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1128,7 +1152,11 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
},
}
if protocol == "udp" {
service["listener"].(map[string]interface{})["metadata"] = map[string]interface{}{"keepAlive": true}
listenerMetadata := map[string]interface{}{"keepAlive": true}
if tunnelTLSProtocol {
listenerMetadata["ttl"] = "10s"
}
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
}
if tunnel != nil && tunnel.Type == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", forward.TunnelID)
+12 -2
View File
@@ -1406,7 +1406,7 @@ func parseIPLiteral(raw string) net.IP {
}
if ip := net.ParseIP(value); ip != nil {
return ip
return normalizeIPAddress(ip)
}
host, _, err := net.SplitHostPort(value)
@@ -1418,7 +1418,17 @@ func parseIPLiteral(raw string) net.IP {
if host == "" {
return nil
}
return net.ParseIP(host)
return normalizeIPAddress(net.ParseIP(host))
}
func normalizeIPAddress(ip net.IP) net.IP {
if ip == nil {
return nil
}
if v4 := ip.To4(); v4 != nil {
return v4
}
return ip.To16()
}
func isTrustedProxyIP(ip net.IP) bool {
@@ -567,6 +567,13 @@ func TestAuthPeerAllowedIPs(t *testing.T) {
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "ipv4-mapped proxy xff allowed",
allowedIPs: "198.51.100.20",
remoteAddr: "[::ffff:172.20.0.3]:34567",
xff: "198.51.100.20, 172.20.0.3",
wantAllowed: true,
},
{
name: "non whitelisted ip denied",
allowedIPs: "203.0.113.10",
@@ -251,7 +251,7 @@ func (h *Handler) pauseForwardRecords(forwards []forwardRecord, now int64) {
func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE user_id = ? AND status = 1
ORDER BY id ASC
@@ -266,7 +266,7 @@ func (h *Handler) listActiveForwardsByUser(userID int64) ([]forwardRecord, error
func (h *Handler) listActiveForwardsByUserTunnel(userID int64, tunnelID int64) ([]forwardRecord, error) {
rows, err := h.repo.DB().Query(`
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, strategy, status
SELECT id, user_id, user_name, name, tunnel_id, remote_addr, COALESCE(strategy, 'fifo'), status
FROM forward
WHERE user_id = ? AND tunnel_id = ? AND status = 1
ORDER BY id ASC
+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)
@@ -2613,9 +2613,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
}
for _, inNode := range state.InNodes {
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
continue
}
node := state.Nodes[inNode.NodeID]
targets := state.OutNodes
if len(state.ChainHops) > 0 {
targets = state.ChainHops[0]
@@ -2625,6 +2623,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
return createdChains, createdServices, err
}
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
continue
}
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
}
createdChains = append(createdChains, inNode.NodeID)
@@ -3035,8 +3036,8 @@ func replaceTunnelChainsTx(tx *store.Tx, tunnelID int64, req map[string]interfac
if nodeID <= 0 {
continue
}
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, NULL, 0, ?)`,
tunnelID, nodeID, defaultString(asString(n["protocol"]), "tls"))
_, err := tx.Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, '1', ?, NULL, ?, 0, ?)`,
tunnelID, nodeID, defaultString(asString(n["strategy"]), "round"), defaultString(asString(n["protocol"]), "tls"))
if err != nil {
return err
}
@@ -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
+75 -7
View File
@@ -727,7 +727,7 @@ func (r *Repository) ListForwards() ([]map[string]interface{}, error) {
}
rows, err := r.db.Query(`
SELECT f.id, f.user_id, f.user_name, f.name, f.tunnel_id, COALESCE(t.name, ''), f.remote_addr, f.strategy,
SELECT f.id, f.user_id, f.user_name, f.name, f.tunnel_id, COALESCE(t.name, ''), f.remote_addr, COALESCE(f.strategy, 'fifo'),
f.in_flow, f.out_flow, f.created_time, f.status, f.inx
FROM forward f
LEFT JOIN tunnel t ON t.id = f.tunnel_id
@@ -1305,7 +1305,7 @@ func bootstrapSchema(db *store.DB, schemaSQL, seedSQL string) error {
return nil
}
const currentSchemaVersion = 1
const currentSchemaVersion = 2
func getSchemaVersion(db *store.DB) int {
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0)`)
@@ -1375,6 +1375,27 @@ func migrateSchema(db *store.DB) error {
}
}
normalizeStrategy := func(table, defaultValue string) error {
_, err := db.Exec(fmt.Sprintf("UPDATE %s SET strategy = ? WHERE strategy IS NULL", table), defaultValue)
if err != nil {
if isMissingTableError(db.Dialect(), err) {
return nil
}
return fmt.Errorf("normalize %s.strategy: %w", table, err)
}
return nil
}
if err := normalizeStrategy("forward", "fifo"); err != nil {
return err
}
if err := normalizeStrategy("chain_tunnel", "round"); err != nil {
return err
}
if err := normalizeStrategy("peer_share_runtime", "round"); err != nil {
return err
}
if db.Dialect() == store.DialectPostgres {
if err := ensurePostgresIDDefaults(db); err != nil {
return err
@@ -1545,6 +1566,17 @@ func isMissingColumnError(dialect store.Dialect, err error) bool {
return strings.Contains(msg, "no such column")
}
func isMissingTableError(dialect store.Dialect, err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
if dialect == store.DialectPostgres {
return strings.Contains(msg, "relation") && strings.Contains(msg, "does not exist")
}
return strings.Contains(msg, "no such table")
}
func (r *Repository) CreatePeerShare(share *PeerShare) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
@@ -2298,13 +2330,25 @@ func (r *Repository) exportTunnels() ([]TunnelBackup, error) {
var tunnels []TunnelBackup
for rows.Next() {
var t TunnelBackup
var protocol sql.NullString
var updatedTime sql.NullInt64
var inIP sql.NullString
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &t.Protocol, &t.Flow, &t.CreatedTime, &t.UpdatedTime, &t.Status, &inIP, &t.Inx); err != nil {
var inx sql.NullInt64
if err := rows.Scan(&t.ID, &t.Name, &t.TrafficRatio, &t.Type, &protocol, &t.Flow, &t.CreatedTime, &updatedTime, &t.Status, &inIP, &inx); err != nil {
return nil, err
}
if protocol.Valid {
t.Protocol = protocol.String
}
if updatedTime.Valid {
t.UpdatedTime = updatedTime.Int64
}
if inIP.Valid {
t.InIP = inIP.String
}
if inx.Valid {
t.Inx = int(inx.Int64)
}
// Export chain tunnels
chainTunnels, err := r.exportChainTunnels(t.ID)
if err != nil {
@@ -2330,12 +2374,23 @@ func (r *Repository) exportChainTunnels(tunnelID int64) ([]ChainTunnelBackup, er
for rows.Next() {
var ct ChainTunnelBackup
var port sql.NullInt64
if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &ct.Strategy, &ct.Inx, &ct.Protocol); err != nil {
var strategy, protocol sql.NullString
var inx sql.NullInt64
if err := rows.Scan(&ct.ID, &ct.TunnelID, &ct.ChainType, &ct.NodeID, &port, &strategy, &inx, &protocol); err != nil {
return nil, err
}
if port.Valid {
ct.Port = int(port.Int64)
}
if strategy.Valid {
ct.Strategy = strategy.String
}
if inx.Valid {
ct.Inx = int(inx.Int64)
}
if protocol.Valid {
ct.Protocol = protocol.String
}
chainTunnels = append(chainTunnels, ct)
}
return chainTunnels, rows.Err()
@@ -2354,9 +2409,21 @@ func (r *Repository) exportForwards() ([]ForwardBackup, error) {
var forwards []ForwardBackup
for rows.Next() {
var f ForwardBackup
if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &f.Strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &f.UpdatedTime, &f.Status, &f.Inx); err != nil {
var strategy sql.NullString
var updatedTime sql.NullInt64
var inx sql.NullInt64
if err := rows.Scan(&f.ID, &f.UserID, &f.UserName, &f.Name, &f.TunnelID, &f.RemoteAddr, &strategy, &f.InFlow, &f.OutFlow, &f.CreatedTime, &updatedTime, &f.Status, &inx); err != nil {
return nil, err
}
if strategy.Valid {
f.Strategy = strategy.String
}
if updatedTime.Valid {
f.UpdatedTime = updatedTime.Int64
}
if inx.Valid {
f.Inx = int(inx.Int64)
}
forwards = append(forwards, f)
}
return forwards, rows.Err()
@@ -2484,7 +2551,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 +2562,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 {
@@ -316,6 +316,136 @@ func TestFederationDualPanelRemoteDiagnosisContract(t *testing.T) {
}
}
func TestFederationDualPanelRemoteEntryRuntimeContract(t *testing.T) {
providerSecret := "provider-contract-jwt"
providerRouter, providerRepo := setupContractRouter(t, providerSecret)
providerServer := httptest.NewServer(providerRouter)
defer providerServer.Close()
consumerSecret := "consumer-contract-jwt"
consumerRouter, consumerRepo := setupContractRouter(t, consumerSecret)
consumerAdminToken, err := auth.GenerateToken(1, "consumer-admin", 0, consumerSecret)
if err != nil {
t.Fatalf("generate consumer admin token: %v", err)
}
now := time.Now().UnixMilli()
providerEntryNodeID := insertContractNode(t, providerRepo, "provider-entry-rt", "198.51.100.21", "43020-43030", "provider-entry-rt-secret", 1)
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle-rt", "198.51.100.22", "44020-44030", "provider-middle-rt-secret", 1)
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit-rt", "198.51.100.23", "45020-45030", "provider-exit-rt-secret", 1)
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "entry-share-rt",
NodeID: providerEntryNodeID,
Token: "share-entry-rt-token",
PortRangeStart: 43020,
PortRangeEnd: 43030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "middle-share-rt",
NodeID: providerMiddleNodeID,
Token: "share-middle-rt-token",
PortRangeStart: 44020,
PortRangeEnd: 44030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
insertPeerShare(t, providerRepo, &sqlite.PeerShare{
Name: "exit-share-rt",
NodeID: providerExitNodeID,
Token: "share-exit-rt-token",
PortRangeStart: 45020,
PortRangeEnd: 45030,
IsActive: 1,
CreatedTime: now,
UpdatedTime: now,
})
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-rt-token")
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-rt-token")
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-rt-token")
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-rt-token")
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-rt-token")
var commandMu sync.Mutex
entryCommands := make([]string, 0, 8)
stopEntry := startMockNodeSessionWithHook(t, providerServer.URL, "provider-entry-rt-secret", func(cmdType string) {
commandMu.Lock()
entryCommands = append(entryCommands, cmdType)
commandMu.Unlock()
})
defer stopEntry()
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-rt-secret")
defer stopMiddle()
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-rt-secret")
defer stopExit()
createTunnel := func(name string) int64 {
payload := map[string]interface{}{
"name": name,
"type": 2,
"flow": 99999,
"status": 1,
"inNodeId": []map[string]interface{}{
{"nodeId": entryRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
"chainNodes": [][]map[string]interface{}{
{{"nodeId": middleRemoteNodeID, "protocol": "tls", "strategy": "round"}},
},
"outNodeId": []map[string]interface{}{
{"nodeId": exitRemoteNodeID, "protocol": "tls", "strategy": "round"},
},
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal create payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/create", bytes.NewReader(body))
req.Header.Set("Authorization", consumerAdminToken)
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
consumerRouter.ServeHTTP(res, req)
assertCode(t, res, 0)
var tunnelID int64
if err := consumerRepo.DB().QueryRow(`SELECT id FROM tunnel WHERE name = ? ORDER BY id DESC LIMIT 1`, name).Scan(&tunnelID); err != nil {
t.Fatalf("query tunnel id (%s): %v", name, err)
}
if tunnelID <= 0 {
t.Fatalf("invalid tunnel id for %s", name)
}
return tunnelID
}
createTunnel("dual-panel-remote-entry-online")
commandMu.Lock()
seenAddChains := false
seenCommands := append([]string(nil), entryCommands...)
for _, cmdType := range entryCommands {
if strings.EqualFold(strings.TrimSpace(cmdType), "AddChains") {
seenAddChains = true
break
}
}
commandMu.Unlock()
if !seenAddChains {
t.Fatalf("expected entry remote node to receive AddChains, commands=%v", seenCommands)
}
stopEntry()
waitNodeStatus(t, providerRepo, providerEntryNodeID, 0)
createTunnel("dual-panel-remote-entry-offline")
}
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
t.Helper()
now := time.Now().UnixMilli()
@@ -409,6 +539,10 @@ func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interf
}
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
return startMockNodeSessionWithHook(t, baseURL, nodeSecret, nil)
}
func startMockNodeSessionWithHook(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
@@ -468,6 +602,9 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
if onCommand != nil {
onCommand(strings.TrimSpace(cmd.Type))
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
@@ -492,9 +629,27 @@ func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func(
}
}()
var stopOnce sync.Once
return func() {
_ = conn.Close()
wg.Wait()
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
func waitNodeStatus(t *testing.T, repo *sqlite.Repository, nodeID int64, expectedStatus int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for {
var status int
if err := repo.DB().QueryRow(`SELECT status FROM node WHERE id = ?`, nodeID).Scan(&status); err == nil && status == expectedStatus {
return
}
if time.Now().After(deadline) {
t.Fatalf("node %d status did not reach %d before timeout", nodeID, expectedStatus)
}
time.Sleep(20 * time.Millisecond)
}
}
@@ -203,6 +203,234 @@ 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)
}
})
t.Run("backup export tolerates nullable legacy tunnel chain fields", func(t *testing.T) {
now := time.Now().UnixMilli()
res, err := repo.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "legacy-null-chain", 1.0, 1, "tls", 1000, now, now, 1, nil, 1)
if err != nil {
t.Fatalf("seed tunnel for nullable chain export: %v", err)
}
tunnelID, err := res.LastInsertId()
if err != nil {
t.Fatalf("read tunnel id for nullable chain export: %v", err)
}
if _, err := repo.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, ?, ?, ?, ?, ?, ?)
`, tunnelID, "1", 1, nil, nil, nil, nil); err != nil {
t.Fatalf("seed nullable chain_tunnel row: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/backup/export", bytes.NewBufferString(`{"types":["tunnels"]}`))
req.Header.Set("Authorization", adminToken)
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
if resp.Code != http.StatusOK {
t.Fatalf("expected status 200, got %d", resp.Code)
}
var payload struct {
Version string `json:"version"`
Tunnels []struct {
ID int64 `json:"id"`
ChainTunnels []struct {
Inx int `json:"inx"`
Strategy string `json:"strategy"`
Protocol string `json:"protocol"`
} `json:"chainTunnels"`
} `json:"tunnels"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
t.Fatalf("decode tunnels backup payload: %v", err)
}
if strings.TrimSpace(payload.Version) == "" {
t.Fatalf("expected backup payload version, got empty")
}
found := false
for _, tunnel := range payload.Tunnels {
if tunnel.ID != tunnelID {
continue
}
if len(tunnel.ChainTunnels) != 1 {
t.Fatalf("expected one chain tunnel for seeded tunnel %d, got %d", tunnelID, len(tunnel.ChainTunnels))
}
if tunnel.ChainTunnels[0].Inx != 0 {
t.Fatalf("expected nullable chain inx to export as 0, got %d", tunnel.ChainTunnels[0].Inx)
}
if tunnel.ChainTunnels[0].Strategy != "" {
t.Fatalf("expected nullable chain strategy to export as empty string, got %q", tunnel.ChainTunnels[0].Strategy)
}
if tunnel.ChainTunnels[0].Protocol != "" {
t.Fatalf("expected nullable chain protocol to export as empty string, got %q", tunnel.ChainTunnels[0].Protocol)
}
found = true
break
}
if !found {
t.Fatalf("expected seeded tunnel %d in backup export", tunnelID)
}
})
}
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")
@@ -2,6 +2,7 @@ package contract_test
import (
"bytes"
"database/sql"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -144,6 +145,17 @@ func TestTunnelUpdateAssignsChainPortsContract(t *testing.T) {
if outPort <= 0 {
t.Fatalf("expected out node port to be assigned, got %d", outPort)
}
var entryStrategy sql.NullString
if err := repo.DB().QueryRow(`SELECT strategy FROM chain_tunnel WHERE tunnel_id = ? AND chain_type = 1 LIMIT 1`, tunnelID).Scan(&entryStrategy); err != nil {
t.Fatalf("query entry strategy: %v", err)
}
if !entryStrategy.Valid || strings.TrimSpace(entryStrategy.String) == "" {
t.Fatalf("expected entry strategy to be non-null and non-empty")
}
if entryStrategy.String != "round" {
t.Fatalf("expected entry strategy round, got %q", entryStrategy.String)
}
}
func jsonInt(v int64) string {
+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`);
+10 -1
View File
@@ -46,6 +46,15 @@ html, body {
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
}
[data-slot="input-wrapper"] {
box-shadow: none;
}
[data-slot="input-wrapper"]:focus-within:not([data-invalid="true"]) {
outline: 2px solid rgb(59 130 246 / 35%);
outline-offset: 1px;
}
.safe-top {
padding-top: var(--safe-area-top);
}
@@ -85,4 +94,4 @@ html, body {
}
}
@config "../../tailwind.config.js"
@config "../../tailwind.config.js"