mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-14 08:44:24 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 177c2bc35f | |||
| 76c0978763 | |||
| fd1168d855 | |||
| 92c9590c1a | |||
| 2afb1d275a | |||
| 880cd4cac5 | |||
| a69a0f040b | |||
| cf6294a77d | |||
| 524ee4cd95 | |||
| c049ceaacf | |||
| 3424221176 | |||
| 5a9715eb26 | |||
| 1b79213aed | |||
| c0d71125f4 | |||
| f01c0481cd |
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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/...
|
||||
```
|
||||
@@ -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/...
|
||||
```
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user