mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 02:36:37 +08:00
Merge pull request #64 from Sagit-chu/opencode/shiny-squid
feat(backend): orchestrate federation runtime for shared middle and exit nodes
This commit is contained in:
@@ -176,6 +176,7 @@ build/
|
|||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
your_app.exe
|
your_app.exe
|
||||||
|
go-backend/paneld
|
||||||
|
|
||||||
# Go 测试二进制文件
|
# Go 测试二进制文件
|
||||||
*.test
|
*.test
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ FLVX (formerly Flux Panel) is a traffic forwarding management system built on a
|
|||||||
|
|
||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `springboot-backend/`.
|
- `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
|
||||||
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
- `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
|
||||||
|
|
||||||
## ANTI-PATTERNS (THIS PROJECT)
|
## ANTI-PATTERNS (THIS PROJECT)
|
||||||
@@ -60,7 +60,7 @@ docker compose -f docker-compose-v6.yml up -d
|
|||||||
./install.sh
|
./install.sh
|
||||||
|
|
||||||
# Local dev (per subproject)
|
# Local dev (per subproject)
|
||||||
(cd springboot-backend && mvn clean package)
|
(cd go-backend && make build)
|
||||||
(cd vite-frontend && npm run dev)
|
(cd vite-frontend && npm run dev)
|
||||||
(cd go-gost && go run .)
|
(cd go-gost && go run .)
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -30,6 +30,45 @@ type RemoteTunnelResponse struct {
|
|||||||
TunnelID int64 `json:"tunnelId"`
|
TunnelID int64 `json:"tunnelId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RuntimeReservePortRequest struct {
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
RequestedPort int `json:"requestedPort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeReservePortResponse struct {
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
BindingID string `json:"bindingId"`
|
||||||
|
AllocatedPort int `json:"allocatedPort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeTarget struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeApplyRoleRequest struct {
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Strategy string `json:"strategy"`
|
||||||
|
Targets []RuntimeTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeApplyRoleResponse struct {
|
||||||
|
BindingID string `json:"bindingId"`
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
AllocatedPort int `json:"allocatedPort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RuntimeReleaseRoleRequest struct {
|
||||||
|
BindingID string `json:"bindingId"`
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func NewFederationClient() *FederationClient {
|
func NewFederationClient() *FederationClient {
|
||||||
return &FederationClient{
|
return &FederationClient{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -119,3 +158,119 @@ func (c *FederationClient) CreateTunnel(url, token, localDomain, protocol string
|
|||||||
|
|
||||||
return &res.Data, nil
|
return &res.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *FederationClient) ReservePort(url, token, localDomain string, reqData RuntimeReservePortRequest) (*RuntimeReservePortResponse, error) {
|
||||||
|
url = strings.TrimSuffix(url, "/")
|
||||||
|
bodyBytes, _ := json.Marshal(reqData)
|
||||||
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/reserve-port", strings.NewReader(string(bodyBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if localDomain != "" {
|
||||||
|
req.Header.Set("X-Panel-Domain", localDomain)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data RuntimeReservePortResponse `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &res.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FederationClient) ApplyRole(url, token, localDomain string, reqData RuntimeApplyRoleRequest) (*RuntimeApplyRoleResponse, error) {
|
||||||
|
url = strings.TrimSuffix(url, "/")
|
||||||
|
bodyBytes, _ := json.Marshal(reqData)
|
||||||
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/apply-role", strings.NewReader(string(bodyBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if localDomain != "" {
|
||||||
|
req.Header.Set("X-Panel-Domain", localDomain)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data RuntimeApplyRoleResponse `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if res.Code != 0 {
|
||||||
|
return nil, fmt.Errorf("remote api error: %s", res.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &res.Data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FederationClient) ReleaseRole(url, token, localDomain string, reqData RuntimeReleaseRoleRequest) error {
|
||||||
|
url = strings.TrimSuffix(url, "/")
|
||||||
|
bodyBytes, _ := json.Marshal(reqData)
|
||||||
|
req, err := http.NewRequest("POST", url+"/api/v1/federation/runtime/release-role", strings.NewReader(string(bodyBytes)))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
if localDomain != "" {
|
||||||
|
req.Header.Set("X-Panel-Domain", localDomain)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
return fmt.Errorf("remote error %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if res.Code != 0 {
|
||||||
|
return fmt.Errorf("remote api error: %s", res.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ type nodeRecord struct {
|
|||||||
TCPListenAddr string
|
TCPListenAddr string
|
||||||
UDPListenAddr string
|
UDPListenAddr string
|
||||||
InterfaceName string
|
InterfaceName string
|
||||||
|
IsRemote int
|
||||||
|
RemoteURL string
|
||||||
|
RemoteToken string
|
||||||
|
RemoteConfig string
|
||||||
}
|
}
|
||||||
|
|
||||||
type chainNodeRecord struct {
|
type chainNodeRecord struct {
|
||||||
@@ -197,7 +201,7 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
|
|||||||
|
|
||||||
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
||||||
row := h.repo.DB().QueryRow(`
|
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
|
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
|
||||||
FROM node
|
FROM node
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
@@ -209,7 +213,10 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
|||||||
var tcpListen sql.NullString
|
var tcpListen sql.NullString
|
||||||
var udpListen sql.NullString
|
var udpListen sql.NullString
|
||||||
var iface sql.NullString
|
var iface sql.NullString
|
||||||
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface)
|
var remoteURL sql.NullString
|
||||||
|
var remoteToken sql.NullString
|
||||||
|
var remoteConfig sql.NullString
|
||||||
|
err := row.Scan(&n.ID, &n.Name, &n.ServerIP, &serverIPv4, &serverIPv6, &n.Status, &portRange, &tcpListen, &udpListen, &iface, &n.IsRemote, &remoteURL, &remoteToken, &remoteConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return nil, errors.New("节点不存在")
|
return nil, errors.New("节点不存在")
|
||||||
@@ -222,6 +229,9 @@ func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
|
|||||||
n.TCPListenAddr = strings.TrimSpace(tcpListen.String)
|
n.TCPListenAddr = strings.TrimSpace(tcpListen.String)
|
||||||
n.UDPListenAddr = strings.TrimSpace(udpListen.String)
|
n.UDPListenAddr = strings.TrimSpace(udpListen.String)
|
||||||
n.InterfaceName = strings.TrimSpace(iface.String)
|
n.InterfaceName = strings.TrimSpace(iface.String)
|
||||||
|
n.RemoteURL = strings.TrimSpace(remoteURL.String)
|
||||||
|
n.RemoteToken = strings.TrimSpace(remoteToken.String)
|
||||||
|
n.RemoteConfig = strings.TrimSpace(remoteConfig.String)
|
||||||
if n.TCPListenAddr == "" {
|
if n.TCPListenAddr == "" {
|
||||||
n.TCPListenAddr = "[::]"
|
n.TCPListenAddr = "[::]"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -37,6 +38,33 @@ type nodeImportRequest struct {
|
|||||||
Token string `json:"token"`
|
Token string `json:"token"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type federationRuntimeReservePortRequest struct {
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
RequestedPort int `json:"requestedPort"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type federationRuntimeTarget struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type federationRuntimeApplyRoleRequest struct {
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
Strategy string `json:"strategy"`
|
||||||
|
Targets []federationRuntimeTarget `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type federationRuntimeReleaseRoleRequest struct {
|
||||||
|
BindingID string `json:"bindingId"`
|
||||||
|
ReservationID string `json:"reservationId"`
|
||||||
|
ResourceKey string `json:"resourceKey"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) federationShareList(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
@@ -183,6 +211,11 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
configBytes, _ := json.Marshal(configData)
|
configBytes, _ := json.Marshal(configData)
|
||||||
|
|
||||||
|
portRange := "0"
|
||||||
|
if info.PortRangeStart > 0 && info.PortRangeEnd >= info.PortRangeStart {
|
||||||
|
portRange = fmt.Sprintf("%d-%d", info.PortRangeStart, info.PortRangeEnd)
|
||||||
|
}
|
||||||
|
|
||||||
db := h.repo.DB()
|
db := h.repo.DB()
|
||||||
inx := nextIndex(db, "node")
|
inx := nextIndex(db, "node")
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
@@ -195,7 +228,7 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
|
|||||||
randomToken(16), // Dummy secret
|
randomToken(16), // Dummy secret
|
||||||
info.ServerIP,
|
info.ServerIP,
|
||||||
"", "", // v4/v6 unknown, use server_ip
|
"", "", // v4/v6 unknown, use server_ip
|
||||||
"0", // port range not applicable for remote
|
portRange,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
now, now,
|
now, now,
|
||||||
@@ -386,6 +419,382 @@ func (h *Handler) federationTunnelCreate(w http.ResponseWriter, r *http.Request)
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationRuntimeReservePort(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
share, err := h.repo.GetPeerShareByToken(token)
|
||||||
|
if err != nil || share == nil {
|
||||||
|
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req federationRuntimeReservePortRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.ResourceKey = strings.TrimSpace(req.ResourceKey)
|
||||||
|
if req.ResourceKey == "" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("resourceKey is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := h.repo.GetPeerShareRuntimeByResourceKey(share.ID, req.ResourceKey)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil && existing.Status == 1 {
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"reservationId": existing.ReservationID,
|
||||||
|
"allocatedPort": existing.Port,
|
||||||
|
"bindingId": existing.BindingID,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
allocatedPort, err := h.pickPeerSharePort(share, req.RequestedPort)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
if existing != nil {
|
||||||
|
existing.Protocol = defaultString(req.Protocol, "tls")
|
||||||
|
existing.Port = allocatedPort
|
||||||
|
existing.BindingID = ""
|
||||||
|
existing.Role = ""
|
||||||
|
existing.ChainName = ""
|
||||||
|
existing.ServiceName = ""
|
||||||
|
existing.Strategy = "round"
|
||||||
|
existing.Target = ""
|
||||||
|
existing.Applied = 0
|
||||||
|
existing.Status = 1
|
||||||
|
existing.UpdatedTime = now
|
||||||
|
if err := h.repo.UpdatePeerShareRuntime(existing); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"reservationId": existing.ReservationID,
|
||||||
|
"allocatedPort": existing.Port,
|
||||||
|
"bindingId": existing.BindingID,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := &sqlite.PeerShareRuntime{
|
||||||
|
ShareID: share.ID,
|
||||||
|
NodeID: share.NodeID,
|
||||||
|
ReservationID: randomToken(24),
|
||||||
|
ResourceKey: req.ResourceKey,
|
||||||
|
BindingID: "",
|
||||||
|
Role: "",
|
||||||
|
ChainName: "",
|
||||||
|
ServiceName: "",
|
||||||
|
Protocol: defaultString(req.Protocol, "tls"),
|
||||||
|
Strategy: "round",
|
||||||
|
Port: allocatedPort,
|
||||||
|
Target: "",
|
||||||
|
Applied: 0,
|
||||||
|
Status: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
}
|
||||||
|
if err := h.repo.CreatePeerShareRuntime(runtime); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"reservationId": runtime.ReservationID,
|
||||||
|
"allocatedPort": runtime.Port,
|
||||||
|
"bindingId": runtime.BindingID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
share, err := h.repo.GetPeerShareByToken(token)
|
||||||
|
if err != nil || share == nil {
|
||||||
|
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req federationRuntimeApplyRoleRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Role = strings.ToLower(strings.TrimSpace(req.Role))
|
||||||
|
if req.Role != "middle" && req.Role != "exit" {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid role"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime *sqlite.PeerShareRuntime
|
||||||
|
if strings.TrimSpace(req.ReservationID) != "" {
|
||||||
|
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
|
||||||
|
} else {
|
||||||
|
runtime, err = h.repo.GetPeerShareRuntimeByResourceKey(share.ID, strings.TrimSpace(req.ResourceKey))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime == nil || runtime.Status == 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Reservation not found"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.Applied == 1 && strings.TrimSpace(runtime.BindingID) != "" {
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"bindingId": runtime.BindingID,
|
||||||
|
"allocatedPort": runtime.Port,
|
||||||
|
"reservationId": runtime.ReservationID,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node, err := h.getNodeRecord(share.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol := defaultString(req.Protocol, runtime.Protocol)
|
||||||
|
strategy := defaultString(req.Strategy, "round")
|
||||||
|
chainName := fmt.Sprintf("fed_chain_%d", runtime.ID)
|
||||||
|
serviceName := fmt.Sprintf("fed_svc_%d", runtime.ID)
|
||||||
|
|
||||||
|
if req.Role == "middle" {
|
||||||
|
if len(req.Targets) == 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("targets are required for middle role"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeItems := make([]map[string]interface{}, 0, len(req.Targets))
|
||||||
|
for i, target := range req.Targets {
|
||||||
|
host := strings.TrimSpace(target.Host)
|
||||||
|
if host == "" || target.Port <= 0 {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid target"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nodeItems = append(nodeItems, map[string]interface{}{
|
||||||
|
"name": fmt.Sprintf("node_%d", i+1),
|
||||||
|
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
|
||||||
|
"connector": map[string]interface{}{
|
||||||
|
"type": "relay",
|
||||||
|
},
|
||||||
|
"dialer": map[string]interface{}{
|
||||||
|
"type": defaultString(target.Protocol, protocol),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
chainData := map[string]interface{}{
|
||||||
|
"name": chainName,
|
||||||
|
"hops": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"name": fmt.Sprintf("hop_%d", runtime.ID),
|
||||||
|
"selector": map[string]interface{}{
|
||||||
|
"strategy": strategy,
|
||||||
|
"maxFails": 1,
|
||||||
|
"failTimeout": int64(600000000000),
|
||||||
|
},
|
||||||
|
"nodes": nodeItems,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(node.InterfaceName) != "" {
|
||||||
|
hops := chainData["hops"].([]map[string]interface{})
|
||||||
|
hops[0]["interface"] = node.InterfaceName
|
||||||
|
}
|
||||||
|
if _, err := h.sendNodeCommand(share.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service := map[string]interface{}{
|
||||||
|
"name": serviceName,
|
||||||
|
"addr": fmt.Sprintf("%s:%d", node.TCPListenAddr, runtime.Port),
|
||||||
|
"handler": map[string]interface{}{
|
||||||
|
"type": "relay",
|
||||||
|
},
|
||||||
|
"listener": map[string]interface{}{
|
||||||
|
"type": protocol,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if req.Role == "middle" {
|
||||||
|
service["handler"].(map[string]interface{})["chain"] = chainName
|
||||||
|
}
|
||||||
|
if req.Role == "exit" && strings.TrimSpace(node.InterfaceName) != "" {
|
||||||
|
service["metadata"] = map[string]interface{}{"interface": node.InterfaceName}
|
||||||
|
}
|
||||||
|
if _, err := h.sendNodeCommand(share.NodeID, "AddService", []map[string]interface{}{service}, true, false); err != nil {
|
||||||
|
if req.Role == "middle" {
|
||||||
|
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
|
||||||
|
}
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targetBytes, _ := json.Marshal(req.Targets)
|
||||||
|
runtime.BindingID = fmt.Sprintf("%d", runtime.ID)
|
||||||
|
runtime.Role = req.Role
|
||||||
|
runtime.ChainName = ""
|
||||||
|
if req.Role == "middle" {
|
||||||
|
runtime.ChainName = chainName
|
||||||
|
}
|
||||||
|
runtime.ServiceName = serviceName
|
||||||
|
runtime.Protocol = protocol
|
||||||
|
runtime.Strategy = strategy
|
||||||
|
runtime.Target = string(targetBytes)
|
||||||
|
runtime.Applied = 1
|
||||||
|
runtime.Status = 1
|
||||||
|
runtime.UpdatedTime = time.Now().UnixMilli()
|
||||||
|
if err := h.repo.UpdatePeerShareRuntime(runtime); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OK(map[string]interface{}{
|
||||||
|
"bindingId": runtime.BindingID,
|
||||||
|
"reservationId": runtime.ReservationID,
|
||||||
|
"allocatedPort": runtime.Port,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationRuntimeReleaseRole(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid method"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := extractBearerToken(r)
|
||||||
|
share, err := h.repo.GetPeerShareByToken(token)
|
||||||
|
if err != nil || share == nil {
|
||||||
|
response.WriteJSON(w, response.Err(401, "Unauthorized"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req federationRuntimeReleaseRoleRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("Invalid JSON"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var runtime *sqlite.PeerShareRuntime
|
||||||
|
if strings.TrimSpace(req.BindingID) != "" {
|
||||||
|
runtime, err = h.repo.GetPeerShareRuntimeByBindingID(share.ID, strings.TrimSpace(req.BindingID))
|
||||||
|
} else if strings.TrimSpace(req.ReservationID) != "" {
|
||||||
|
runtime, err = h.repo.GetPeerShareRuntimeByReservationID(share.ID, strings.TrimSpace(req.ReservationID))
|
||||||
|
} else if strings.TrimSpace(req.ResourceKey) != "" {
|
||||||
|
runtime, err = h.repo.GetPeerShareRuntimeByResourceKey(share.ID, strings.TrimSpace(req.ResourceKey))
|
||||||
|
} else {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("bindingId or reservationId or resourceKey is required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime == nil {
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if runtime.Applied == 1 {
|
||||||
|
if strings.TrimSpace(runtime.ServiceName) != "" {
|
||||||
|
_, _ = h.sendNodeCommand(share.NodeID, "DeleteService", map[string]interface{}{"services": []string{runtime.ServiceName}}, false, true)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(runtime.Role) == "middle" && strings.TrimSpace(runtime.ChainName) != "" {
|
||||||
|
_, _ = h.sendNodeCommand(share.NodeID, "DeleteChains", map[string]interface{}{"chain": runtime.ChainName}, false, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.MarkPeerShareRuntimeReleased(runtime.ID, time.Now().UnixMilli()); err != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) pickPeerSharePort(share *sqlite.PeerShare, requestedPort int) (int, error) {
|
||||||
|
if share == nil {
|
||||||
|
return 0, fmt.Errorf("share not found")
|
||||||
|
}
|
||||||
|
if share.PortRangeStart <= 0 || share.PortRangeEnd <= 0 || share.PortRangeEnd < share.PortRangeStart {
|
||||||
|
return 0, fmt.Errorf("No available port")
|
||||||
|
}
|
||||||
|
|
||||||
|
used := make(map[int]struct{})
|
||||||
|
|
||||||
|
rows, err := h.repo.DB().Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND port > 0`, share.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var p sql.NullInt64
|
||||||
|
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||||
|
used[int(p.Int64)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
rows, err = h.repo.DB().Query(`SELECT port FROM forward_port WHERE node_id = ? AND port > 0`, share.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var p sql.NullInt64
|
||||||
|
if scanErr := rows.Scan(&p); scanErr == nil && p.Valid && p.Int64 > 0 {
|
||||||
|
used[int(p.Int64)] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = rows.Close()
|
||||||
|
|
||||||
|
ports, err := h.repo.ListActivePeerShareRuntimePorts(share.ID, share.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
for _, p := range ports {
|
||||||
|
if p > 0 {
|
||||||
|
used[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestedPort > 0 {
|
||||||
|
if requestedPort < share.PortRangeStart || requestedPort > share.PortRangeEnd {
|
||||||
|
return 0, fmt.Errorf("Port out of range")
|
||||||
|
}
|
||||||
|
if _, ok := used[requestedPort]; ok {
|
||||||
|
return 0, fmt.Errorf("No available port")
|
||||||
|
}
|
||||||
|
return requestedPort, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for p := share.PortRangeStart; p <= share.PortRangeEnd; p++ {
|
||||||
|
if _, ok := used[p]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0, fmt.Errorf("No available port")
|
||||||
|
}
|
||||||
|
|
||||||
func extractBearerToken(r *http.Request) string {
|
func extractBearerToken(r *http.Request) string {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
parts := strings.Split(authHeader, " ")
|
parts := strings.Split(authHeader, " ")
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPickPeerSharePortUsesRuntimeReservations(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
h := &Handler{repo: repo}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol) VALUES(?, ?, ?, ?, ?, ?, ?)`, 1, 2, 1, 3000, "round", 1, "tls"); err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, 1, 3001); err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, 77, 1, "res-1", "rk-1", "b-1", "exit", "", "fed_svc_1", "tls", "round", 3002, "", 1, 1, now, now); err != nil {
|
||||||
|
t.Fatalf("insert peer_share_runtime: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
share := &sqlite.PeerShare{
|
||||||
|
ID: 77,
|
||||||
|
NodeID: 1,
|
||||||
|
PortRangeStart: 3000,
|
||||||
|
PortRangeEnd: 3004,
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := h.pickPeerSharePort(share, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pick auto port: %v", err)
|
||||||
|
}
|
||||||
|
if port != 3003 {
|
||||||
|
t.Fatalf("expected port 3003, got %d", port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.pickPeerSharePort(share, 3001); err == nil {
|
||||||
|
t.Fatalf("expected requested busy port to fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyTunnelRuntimeSkipsRemoteNodes(t *testing.T) {
|
||||||
|
h := &Handler{}
|
||||||
|
state := &tunnelCreateState{
|
||||||
|
TunnelID: 1,
|
||||||
|
Type: 2,
|
||||||
|
InNodes: []tunnelRuntimeNode{
|
||||||
|
{NodeID: 11, ChainType: 1, Protocol: "tls"},
|
||||||
|
},
|
||||||
|
ChainHops: [][]tunnelRuntimeNode{
|
||||||
|
{
|
||||||
|
{NodeID: 12, ChainType: 2, Inx: 1, Port: 41000, Protocol: "tls", Strategy: "round"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
OutNodes: []tunnelRuntimeNode{
|
||||||
|
{NodeID: 13, ChainType: 3, Port: 42000, Protocol: "tls", Strategy: "round"},
|
||||||
|
},
|
||||||
|
Nodes: map[int64]*nodeRecord{
|
||||||
|
11: {ID: 11, Name: "remote-in", IsRemote: 1},
|
||||||
|
12: {ID: 12, Name: "remote-chain", IsRemote: 1},
|
||||||
|
13: {ID: 13, Name: "remote-out", IsRemote: 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chains, services, err := h.applyTunnelRuntime(state)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("apply runtime: %v", err)
|
||||||
|
}
|
||||||
|
if len(chains) != 0 {
|
||||||
|
t.Fatalf("expected no local chains created, got %d", len(chains))
|
||||||
|
}
|
||||||
|
if len(services) != 0 {
|
||||||
|
t.Fatalf("expected no local services created, got %d", len(services))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareTunnelCreateStateRemoteAutoPortDefersToFederation(t *testing.T) {
|
||||||
|
repo, err := sqlite.Open(filepath.Join(t.TempDir(), "panel.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open repo: %v", err)
|
||||||
|
}
|
||||||
|
defer repo.Close()
|
||||||
|
|
||||||
|
h := &Handler{repo: repo}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
insertNode := func(name string, status int, portRange string, isRemote int) int64 {
|
||||||
|
res, execErr := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx, is_remote, remote_url, remote_token, remote_config)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, name+"-secret", "10.0.0.1", "10.0.0.1", "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0, isRemote, "http://peer", "peer-token", `{"shareId":1}`)
|
||||||
|
if execErr != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, execErr)
|
||||||
|
}
|
||||||
|
id, idErr := res.LastInsertId()
|
||||||
|
if idErr != nil {
|
||||||
|
t.Fatalf("node id %s: %v", name, idErr)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
entryID := insertNode("entry", 1, "31000-31010", 0)
|
||||||
|
remoteOutID := insertNode("remote-out", 1, "30000", 1)
|
||||||
|
|
||||||
|
if _, err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, 1, remoteOutID, 30000); err != nil {
|
||||||
|
t.Fatalf("insert forward_port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := repo.DB().Begin()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("begin tx: %v", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
req := map[string]interface{}{
|
||||||
|
"name": "test-tunnel",
|
||||||
|
"inNodeId": []interface{}{
|
||||||
|
map[string]interface{}{"nodeId": float64(entryID), "protocol": "tls", "strategy": "round"},
|
||||||
|
},
|
||||||
|
"outNodeId": []interface{}{
|
||||||
|
map[string]interface{}{"nodeId": float64(remoteOutID), "protocol": "tls", "strategy": "round", "port": float64(0)},
|
||||||
|
},
|
||||||
|
"chainNodes": []interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := h.prepareTunnelCreateState(tx, req, 2, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("prepare state should not fail for remote auto-port: %v", err)
|
||||||
|
}
|
||||||
|
if len(state.OutNodes) != 1 {
|
||||||
|
t.Fatalf("expected 1 out node, got %d", len(state.OutNodes))
|
||||||
|
}
|
||||||
|
if state.OutNodes[0].Port != 0 {
|
||||||
|
t.Fatalf("expected remote out port to remain 0 before federation reserve, got %d", state.OutNodes[0].Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,9 @@ type Handler struct {
|
|||||||
jwtSecret string
|
jwtSecret string
|
||||||
wsServer *ws.Server
|
wsServer *ws.Server
|
||||||
|
|
||||||
|
captchaMu sync.Mutex
|
||||||
|
captchaTokens map[string]int64
|
||||||
|
|
||||||
jobsMu sync.Mutex
|
jobsMu sync.Mutex
|
||||||
jobsCancel context.CancelFunc
|
jobsCancel context.CancelFunc
|
||||||
jobsStarted bool
|
jobsStarted bool
|
||||||
@@ -39,6 +42,11 @@ type loginRequest struct {
|
|||||||
CaptchaID string `json:"captchaId"`
|
CaptchaID string `json:"captchaId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type captchaVerifyRequest struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
type nameRequest struct {
|
type nameRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
@@ -63,9 +71,10 @@ type flowItem struct {
|
|||||||
|
|
||||||
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
func New(repo *sqlite.Repository, jwtSecret string) *Handler {
|
||||||
return &Handler{
|
return &Handler{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
jwtSecret: jwtSecret,
|
jwtSecret: jwtSecret,
|
||||||
wsServer: ws.NewServer(repo, jwtSecret),
|
wsServer: ws.NewServer(repo, jwtSecret),
|
||||||
|
captchaTokens: make(map[string]int64),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,6 +94,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
mux.HandleFunc("/api/v1/config/update", h.updateConfigs)
|
||||||
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
mux.HandleFunc("/api/v1/config/update-single", h.updateSingleConfig)
|
||||||
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
mux.HandleFunc("/api/v1/captcha/check", h.checkCaptcha)
|
||||||
|
mux.HandleFunc("/api/v1/captcha/verify", h.captchaVerify)
|
||||||
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
mux.HandleFunc("/api/v1/user/package", h.userPackage)
|
||||||
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
|
mux.HandleFunc("/api/v1/user/updatePassword", h.updatePassword)
|
||||||
mux.HandleFunc("/api/v1/node/list", h.nodeList)
|
mux.HandleFunc("/api/v1/node/list", h.nodeList)
|
||||||
@@ -148,6 +158,9 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
mux.HandleFunc("/api/v1/federation/share/delete", h.federationShareDelete)
|
||||||
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
mux.HandleFunc("/api/v1/federation/connect", h.authPeer(h.federationConnect))
|
||||||
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
mux.HandleFunc("/api/v1/federation/tunnel/create", h.authPeer(h.federationTunnelCreate))
|
||||||
|
mux.HandleFunc("/api/v1/federation/runtime/reserve-port", h.authPeer(h.federationRuntimeReservePort))
|
||||||
|
mux.HandleFunc("/api/v1/federation/runtime/apply-role", h.authPeer(h.federationRuntimeApplyRole))
|
||||||
|
mux.HandleFunc("/api/v1/federation/runtime/release-role", h.authPeer(h.federationRuntimeReleaseRole))
|
||||||
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport)
|
||||||
|
|
||||||
mux.HandleFunc("/flow/test", h.flowTest)
|
mux.HandleFunc("/flow/test", h.flowTest)
|
||||||
@@ -183,20 +196,23 @@ func (h *Handler) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if captchaEnabled {
|
if captchaEnabled {
|
||||||
if strings.TrimSpace(req.CaptchaID) == "" {
|
captchaID := strings.TrimSpace(req.CaptchaID)
|
||||||
|
if captchaID == "" {
|
||||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
if !h.consumeCaptchaToken(captchaID) {
|
||||||
if err != nil || secretCfg == nil || secretCfg.Value == "" {
|
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||||
response.WriteJSON(w, response.ErrDefault("验证码配置错误:未配置Secret Key"))
|
if err != nil || secretCfg == nil || strings.TrimSpace(secretCfg.Value) == "" {
|
||||||
return
|
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if !h.verifyCloudflareTurnstile(req.CaptchaID, secretCfg.Value) {
|
if !h.verifyCloudflareTurnstile(captchaID, strings.TrimSpace(secretCfg.Value)) {
|
||||||
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
response.WriteJSON(w, response.ErrDefault("验证码校验失败"))
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -585,6 +601,40 @@ func (h *Handler) checkCaptcha(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.OK(0))
|
response.WriteJSON(w, response.OK(0))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) captchaVerify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req captchaVerifyRequest
|
||||||
|
if err := decodeJSON(r.Body, &req); err != nil {
|
||||||
|
h.writeCaptchaVerifyResult(w, false, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := strings.TrimSpace(req.ID)
|
||||||
|
data := strings.TrimSpace(req.Data)
|
||||||
|
if id == "" || data == "" {
|
||||||
|
h.writeCaptchaVerifyResult(w, false, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
verified := false
|
||||||
|
secretCfg, err := h.repo.GetConfigByName("cloudflare_secret_key")
|
||||||
|
if err == nil && secretCfg != nil && strings.TrimSpace(secretCfg.Value) != "" {
|
||||||
|
verified = h.verifyCloudflareTurnstile(data, strings.TrimSpace(secretCfg.Value))
|
||||||
|
} else {
|
||||||
|
verified = data == "ok"
|
||||||
|
}
|
||||||
|
if !verified {
|
||||||
|
h.writeCaptchaVerifyResult(w, false, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.markCaptchaToken(id)
|
||||||
|
h.writeCaptchaVerifyResult(w, true, id)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) flowTest(w http.ResponseWriter, _ *http.Request) {
|
func (h *Handler) flowTest(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
_, _ = w.Write([]byte("test"))
|
_, _ = w.Write([]byte("test"))
|
||||||
@@ -899,6 +949,69 @@ func (h *Handler) captchaEnabled() (bool, error) {
|
|||||||
return strings.EqualFold(cfg.Value, "true"), nil
|
return strings.EqualFold(cfg.Value, "true"), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) markCaptchaToken(token string) {
|
||||||
|
if h == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
exp := now + int64(5*time.Minute/time.Millisecond)
|
||||||
|
|
||||||
|
h.captchaMu.Lock()
|
||||||
|
defer h.captchaMu.Unlock()
|
||||||
|
if h.captchaTokens == nil {
|
||||||
|
h.captchaTokens = make(map[string]int64)
|
||||||
|
}
|
||||||
|
for k, v := range h.captchaTokens {
|
||||||
|
if v <= now {
|
||||||
|
delete(h.captchaTokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.captchaTokens[token] = exp
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) consumeCaptchaToken(token string) bool {
|
||||||
|
if h == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
token = strings.TrimSpace(token)
|
||||||
|
if token == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
h.captchaMu.Lock()
|
||||||
|
defer h.captchaMu.Unlock()
|
||||||
|
if h.captchaTokens == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for k, v := range h.captchaTokens {
|
||||||
|
if v <= now {
|
||||||
|
delete(h.captchaTokens, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exp, ok := h.captchaTokens[token]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
delete(h.captchaTokens, token)
|
||||||
|
return exp > now
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeCaptchaVerifyResult(w http.ResponseWriter, success bool, token string) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"success": success,
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"validToken": token,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(payload)
|
||||||
|
}
|
||||||
|
|
||||||
func decodeJSON(body io.ReadCloser, out interface{}) error {
|
func decodeJSON(body io.ReadCloser, out interface{}) error {
|
||||||
defer body.Close()
|
defer body.Close()
|
||||||
decoder := json.NewDecoder(body)
|
decoder := json.NewDecoder(body)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"go-backend/internal/http/client"
|
"go-backend/internal/http/client"
|
||||||
"go-backend/internal/http/response"
|
"go-backend/internal/http/response"
|
||||||
"go-backend/internal/security"
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -505,7 +506,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
|
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
@@ -566,12 +567,28 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
tunnelID, _ := res.LastInsertId()
|
tunnelID, _ := res.LastInsertId()
|
||||||
runtimeState.TunnelID = tunnelID
|
runtimeState.TunnelID = tunnelID
|
||||||
|
var federationBindings []sqlite.FederationTunnelBinding
|
||||||
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
|
if typeVal == 2 {
|
||||||
|
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
applyTunnelPortsToRequest(req, runtimeState)
|
applyTunnelPortsToRequest(req, runtimeState)
|
||||||
if err := replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
if err := replaceTunnelChainsTx(tx, tunnelID, req); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := replaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -579,6 +596,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
|
|||||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
|
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
_ = h.deleteTunnelByID(tunnelID)
|
_ = h.deleteTunnelByID(tunnelID)
|
||||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||||
return
|
return
|
||||||
@@ -652,6 +670,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.cleanupTunnelRuntime(id)
|
h.cleanupTunnelRuntime(id)
|
||||||
|
h.cleanupFederationRuntime(id)
|
||||||
|
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
typeVal := asInt(req["type"], 1)
|
typeVal := asInt(req["type"], 1)
|
||||||
@@ -663,12 +682,21 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal)
|
runtimeState, err := h.prepareTunnelCreateState(tx, req, typeVal, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
runtimeState.TunnelID = id
|
runtimeState.TunnelID = id
|
||||||
|
var federationBindings []sqlite.FederationTunnelBinding
|
||||||
|
var federationReleaseRefs []federationRuntimeReleaseRef
|
||||||
|
if typeVal == 2 {
|
||||||
|
federationBindings, federationReleaseRefs, err = h.applyFederationRuntime(runtimeState)
|
||||||
|
if err != nil {
|
||||||
|
response.WriteJSON(w, response.ErrDefault(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
applyTunnelPortsToRequest(req, runtimeState)
|
applyTunnelPortsToRequest(req, runtimeState)
|
||||||
|
|
||||||
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
_, err = tx.Exec(`UPDATE tunnel SET name=?, type=?, flow=?, traffic_ratio=?, status=?, in_ip=?, updated_time=? WHERE id=?`,
|
||||||
@@ -683,10 +711,17 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := replaceTunnelChainsTx(tx, id, req); err != nil {
|
if err := replaceTunnelChainsTx(tx, id, req); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := replaceFederationTunnelBindingsTx(tx, id, federationBindings); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -695,6 +730,12 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
|
|||||||
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
h.rollbackTunnelRuntime(createdChains, createdServices, id)
|
h.rollbackTunnelRuntime(createdChains, createdServices, id)
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
|
||||||
|
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
|
||||||
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
|
return
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -713,6 +754,7 @@ func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.cleanupTunnelRuntime(id)
|
h.cleanupTunnelRuntime(id)
|
||||||
|
h.cleanupFederationRuntime(id)
|
||||||
if err := h.deleteTunnelByID(id); err != nil {
|
if err := h.deleteTunnelByID(id); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -771,6 +813,7 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
fail := 0
|
fail := 0
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
h.cleanupTunnelRuntime(id)
|
h.cleanupTunnelRuntime(id)
|
||||||
|
h.cleanupFederationRuntime(id)
|
||||||
if err := h.deleteTunnelByID(id); err != nil {
|
if err := h.deleteTunnelByID(id); err != nil {
|
||||||
fail++
|
fail++
|
||||||
} else {
|
} else {
|
||||||
@@ -872,13 +915,38 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
if tunnel.Type == 2 {
|
if tunnel.Type == 2 {
|
||||||
h.cleanupTunnelRuntime(tunnelID)
|
h.cleanupTunnelRuntime(tunnelID)
|
||||||
|
h.cleanupFederationRuntime(tunnelID)
|
||||||
state, err := h.reconstructTunnelState(tunnelID)
|
state, err := h.reconstructTunnelState(tunnelID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail++
|
fail++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
federationBindings, federationReleaseRefs, fedErr := h.applyFederationRuntime(state)
|
||||||
|
if fedErr != nil {
|
||||||
|
fail++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tx, txErr := h.repo.DB().Begin()
|
||||||
|
if txErr != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
fail++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if replaceErr := replaceFederationTunnelBindingsTx(tx, tunnelID, federationBindings); replaceErr != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
fail++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if commitErr := tx.Commit(); commitErr != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
fail++
|
||||||
|
continue
|
||||||
|
}
|
||||||
_, _, applyErr := h.applyTunnelRuntime(state)
|
_, _, applyErr := h.applyTunnelRuntime(state)
|
||||||
if applyErr != nil {
|
if applyErr != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(federationReleaseRefs)
|
||||||
|
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||||
fail++
|
fail++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -1876,7 +1944,7 @@ type tunnelCreateState struct {
|
|||||||
NodeIDList []int64
|
NodeIDList []int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int) (*tunnelCreateState, error) {
|
func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{}, tunnelType int, excludeTunnelID int64) (*tunnelCreateState, error) {
|
||||||
state := &tunnelCreateState{
|
state := &tunnelCreateState{
|
||||||
Type: tunnelType,
|
Type: tunnelType,
|
||||||
InNodes: make([]tunnelRuntimeNode, 0),
|
InNodes: make([]tunnelRuntimeNode, 0),
|
||||||
@@ -1918,10 +1986,16 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
|||||||
nodeIDs = append(nodeIDs, nodeID)
|
nodeIDs = append(nodeIDs, nodeID)
|
||||||
port := asInt(item["port"], 0)
|
port := asInt(item["port"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
var err error
|
isRemote, remoteErr := isRemoteNodeTx(tx, nodeID)
|
||||||
port, err = pickNodePortTx(tx, nodeID, allocated)
|
if remoteErr != nil {
|
||||||
if err != nil {
|
return nil, remoteErr
|
||||||
return nil, err
|
}
|
||||||
|
if !isRemote {
|
||||||
|
var err error
|
||||||
|
port, err = pickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
|
state.OutNodes = append(state.OutNodes, tunnelRuntimeNode{
|
||||||
@@ -1946,10 +2020,16 @@ func (h *Handler) prepareTunnelCreateState(tx *sql.Tx, req map[string]interface{
|
|||||||
nodeIDs = append(nodeIDs, nodeID)
|
nodeIDs = append(nodeIDs, nodeID)
|
||||||
port := asInt(item["port"], 0)
|
port := asInt(item["port"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
var err error
|
isRemote, remoteErr := isRemoteNodeTx(tx, nodeID)
|
||||||
port, err = pickNodePortTx(tx, nodeID, allocated)
|
if remoteErr != nil {
|
||||||
if err != nil {
|
return nil, remoteErr
|
||||||
return nil, err
|
}
|
||||||
|
if !isRemote {
|
||||||
|
var err error
|
||||||
|
port, err = pickNodePortTx(tx, nodeID, allocated, excludeTunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hop = append(hop, tunnelRuntimeNode{
|
hop = append(hop, tunnelRuntimeNode{
|
||||||
@@ -2053,6 +2133,303 @@ func applyTunnelPortsToRequest(req map[string]interface{}, state *tunnelCreateSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type federationRuntimeReleaseRef struct {
|
||||||
|
RemoteURL string
|
||||||
|
RemoteToken string
|
||||||
|
BindingID string
|
||||||
|
ReservationID string
|
||||||
|
ResourceKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
func federationRuntimeResourceKey(tunnelID int64, nodeID int64, chainType int, hopInx int) string {
|
||||||
|
return fmt.Sprintf("tunnel:%d:node:%d:type:%d:hop:%d", tunnelID, nodeID, chainType, hopInx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func remoteShareIDFromConfig(raw string) int64 {
|
||||||
|
raw = strings.TrimSpace(raw)
|
||||||
|
if raw == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return asInt64(cfg["shareId"], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) federationLocalDomain() string {
|
||||||
|
cfg, _ := h.repo.GetConfigByName("panel_domain")
|
||||||
|
if cfg == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(cfg.Value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) applyFederationRuntime(state *tunnelCreateState) ([]sqlite.FederationTunnelBinding, []federationRuntimeReleaseRef, error) {
|
||||||
|
bindings := make([]sqlite.FederationTunnelBinding, 0)
|
||||||
|
releaseRefs := make([]federationRuntimeReleaseRef, 0)
|
||||||
|
if h == nil || state == nil || state.Type != 2 {
|
||||||
|
return bindings, releaseRefs, nil
|
||||||
|
}
|
||||||
|
fc := client.NewFederationClient()
|
||||||
|
localDomain := h.federationLocalDomain()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
for outIdx := range state.OutNodes {
|
||||||
|
outNode := state.OutNodes[outIdx]
|
||||||
|
node := state.Nodes[outNode.NodeID]
|
||||||
|
if node == nil || node.IsRemote != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||||
|
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||||
|
if remoteURL == "" || remoteToken == "" {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceKey := federationRuntimeResourceKey(state.TunnelID, outNode.NodeID, 3, 0)
|
||||||
|
reserveReq := client.RuntimeReservePortRequest{
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||||
|
RequestedPort: outNode.Port,
|
||||||
|
}
|
||||||
|
reserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||||
|
if err != nil && reserveReq.RequestedPort > 0 {
|
||||||
|
reserveReq.RequestedPort = 0
|
||||||
|
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.OutNodes[outIdx].Port = reserveRes.AllocatedPort
|
||||||
|
outNode = state.OutNodes[outIdx]
|
||||||
|
|
||||||
|
applyReq := client.RuntimeApplyRoleRequest{
|
||||||
|
ReservationID: reserveRes.ReservationID,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
Role: "exit",
|
||||||
|
Protocol: defaultString(outNode.Protocol, "tls"),
|
||||||
|
Strategy: defaultString(outNode.Strategy, "round"),
|
||||||
|
}
|
||||||
|
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
|
||||||
|
if err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
|
||||||
|
}
|
||||||
|
if applyRes.AllocatedPort > 0 {
|
||||||
|
state.OutNodes[outIdx].Port = applyRes.AllocatedPort
|
||||||
|
outNode = state.OutNodes[outIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = append(bindings, sqlite.FederationTunnelBinding{
|
||||||
|
TunnelID: state.TunnelID,
|
||||||
|
NodeID: outNode.NodeID,
|
||||||
|
ChainType: 3,
|
||||||
|
HopInx: 0,
|
||||||
|
RemoteURL: remoteURL,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
RemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),
|
||||||
|
AllocatedPort: outNode.Port,
|
||||||
|
Status: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
|
||||||
|
RemoteURL: remoteURL,
|
||||||
|
RemoteToken: remoteToken,
|
||||||
|
BindingID: applyRes.BindingID,
|
||||||
|
ReservationID: reserveRes.ReservationID,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for hopIdx := len(state.ChainHops) - 1; hopIdx >= 0; hopIdx-- {
|
||||||
|
for nodeIdx := range state.ChainHops[hopIdx] {
|
||||||
|
chainNode := state.ChainHops[hopIdx][nodeIdx]
|
||||||
|
node := state.Nodes[chainNode.NodeID]
|
||||||
|
if node == nil || node.IsRemote != 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteURL := strings.TrimSpace(node.RemoteURL)
|
||||||
|
remoteToken := strings.TrimSpace(node.RemoteToken)
|
||||||
|
if remoteURL == "" || remoteToken == "" {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 缺少共享配置", nodeDisplayName(node))
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceKey := federationRuntimeResourceKey(state.TunnelID, chainNode.NodeID, 2, hopIdx+1)
|
||||||
|
reserveReq := client.RuntimeReservePortRequest{
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||||
|
RequestedPort: chainNode.Port,
|
||||||
|
}
|
||||||
|
reserveRes, err := fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||||
|
if err != nil && reserveReq.RequestedPort > 0 {
|
||||||
|
reserveReq.RequestedPort = 0
|
||||||
|
reserveRes, err = fc.ReservePort(remoteURL, remoteToken, localDomain, reserveReq)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 端口分配失败: %w", nodeDisplayName(node), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
state.ChainHops[hopIdx][nodeIdx].Port = reserveRes.AllocatedPort
|
||||||
|
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||||
|
|
||||||
|
nextTargets := state.OutNodes
|
||||||
|
if hopIdx+1 < len(state.ChainHops) {
|
||||||
|
nextTargets = state.ChainHops[hopIdx+1]
|
||||||
|
}
|
||||||
|
applyTargets := make([]client.RuntimeTarget, 0, len(nextTargets))
|
||||||
|
for _, target := range nextTargets {
|
||||||
|
targetNode := state.Nodes[target.NodeID]
|
||||||
|
if targetNode == nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
host, hostErr := selectTunnelDialHost(node, targetNode)
|
||||||
|
if hostErr != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, hostErr
|
||||||
|
}
|
||||||
|
if target.Port <= 0 {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, errors.New("节点端口不能为空")
|
||||||
|
}
|
||||||
|
applyTargets = append(applyTargets, client.RuntimeTarget{
|
||||||
|
Host: host,
|
||||||
|
Port: target.Port,
|
||||||
|
Protocol: defaultString(target.Protocol, "tls"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
applyReq := client.RuntimeApplyRoleRequest{
|
||||||
|
ReservationID: reserveRes.ReservationID,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
Role: "middle",
|
||||||
|
Protocol: defaultString(chainNode.Protocol, "tls"),
|
||||||
|
Strategy: defaultString(chainNode.Strategy, "round"),
|
||||||
|
Targets: applyTargets,
|
||||||
|
}
|
||||||
|
applyRes, err := fc.ApplyRole(remoteURL, remoteToken, localDomain, applyReq)
|
||||||
|
if err != nil {
|
||||||
|
h.releaseFederationRuntimeRefs(releaseRefs)
|
||||||
|
return nil, nil, fmt.Errorf("远程节点 %s 运行时下发失败: %w", nodeDisplayName(node), err)
|
||||||
|
}
|
||||||
|
if applyRes.AllocatedPort > 0 {
|
||||||
|
state.ChainHops[hopIdx][nodeIdx].Port = applyRes.AllocatedPort
|
||||||
|
chainNode = state.ChainHops[hopIdx][nodeIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
bindings = append(bindings, sqlite.FederationTunnelBinding{
|
||||||
|
TunnelID: state.TunnelID,
|
||||||
|
NodeID: chainNode.NodeID,
|
||||||
|
ChainType: 2,
|
||||||
|
HopInx: hopIdx + 1,
|
||||||
|
RemoteURL: remoteURL,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
RemoteBindingID: defaultString(applyRes.BindingID, reserveRes.BindingID),
|
||||||
|
AllocatedPort: chainNode.Port,
|
||||||
|
Status: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
releaseRefs = append(releaseRefs, federationRuntimeReleaseRef{
|
||||||
|
RemoteURL: remoteURL,
|
||||||
|
RemoteToken: remoteToken,
|
||||||
|
BindingID: applyRes.BindingID,
|
||||||
|
ReservationID: reserveRes.ReservationID,
|
||||||
|
ResourceKey: resourceKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bindings, releaseRefs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) releaseFederationRuntimeRefs(refs []federationRuntimeReleaseRef) {
|
||||||
|
if h == nil || len(refs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fc := client.NewFederationClient()
|
||||||
|
localDomain := h.federationLocalDomain()
|
||||||
|
for i := len(refs) - 1; i >= 0; i-- {
|
||||||
|
ref := refs[i]
|
||||||
|
if strings.TrimSpace(ref.RemoteURL) == "" || strings.TrimSpace(ref.RemoteToken) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req := client.RuntimeReleaseRoleRequest{
|
||||||
|
BindingID: ref.BindingID,
|
||||||
|
ReservationID: ref.ReservationID,
|
||||||
|
ResourceKey: ref.ResourceKey,
|
||||||
|
}
|
||||||
|
_ = fc.ReleaseRole(ref.RemoteURL, ref.RemoteToken, localDomain, req)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cleanupFederationRuntime(tunnelID int64) {
|
||||||
|
if h == nil || tunnelID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bindings, err := h.repo.ListActiveFederationTunnelBindingsByTunnel(tunnelID)
|
||||||
|
if err != nil || len(bindings) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fc := client.NewFederationClient()
|
||||||
|
localDomain := h.federationLocalDomain()
|
||||||
|
for _, b := range bindings {
|
||||||
|
node, nodeErr := h.repo.GetNodeByID(b.NodeID)
|
||||||
|
if nodeErr != nil || node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remoteURL := strings.TrimSpace(node.RemoteURL.String)
|
||||||
|
if remoteURL == "" {
|
||||||
|
remoteURL = strings.TrimSpace(b.RemoteURL)
|
||||||
|
}
|
||||||
|
remoteToken := strings.TrimSpace(node.RemoteToken.String)
|
||||||
|
if remoteURL == "" || remoteToken == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
req := client.RuntimeReleaseRoleRequest{
|
||||||
|
BindingID: strings.TrimSpace(b.RemoteBindingID),
|
||||||
|
ResourceKey: strings.TrimSpace(b.ResourceKey),
|
||||||
|
}
|
||||||
|
_ = fc.ReleaseRole(remoteURL, remoteToken, localDomain, req)
|
||||||
|
}
|
||||||
|
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(tunnelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFederationTunnelBindingsTx(tx *sql.Tx, tunnelID int64, bindings []sqlite.FederationTunnelBinding) error {
|
||||||
|
if tx == nil {
|
||||||
|
return errors.New("database unavailable")
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, tunnelID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, b := range bindings {
|
||||||
|
created := b.CreatedTime
|
||||||
|
if created <= 0 {
|
||||||
|
created = time.Now().UnixMilli()
|
||||||
|
}
|
||||||
|
updated := b.UpdatedTime
|
||||||
|
if updated <= 0 {
|
||||||
|
updated = created
|
||||||
|
}
|
||||||
|
_, err := tx.Exec(`
|
||||||
|
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, tunnelID, b.NodeID, b.ChainType, b.HopInx, b.RemoteURL, b.ResourceKey, b.RemoteBindingID, b.AllocatedPort, b.Status, created, updated)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {
|
func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64, error) {
|
||||||
if h == nil || state == nil {
|
if h == nil || state == nil {
|
||||||
return nil, nil, errors.New("invalid tunnel runtime state")
|
return nil, nil, errors.New("invalid tunnel runtime state")
|
||||||
@@ -2064,6 +2441,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, inNode := range state.InNodes {
|
for _, inNode := range state.InNodes {
|
||||||
|
if node := state.Nodes[inNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
targets := state.OutNodes
|
targets := state.OutNodes
|
||||||
if len(state.ChainHops) > 0 {
|
if len(state.ChainHops) > 0 {
|
||||||
targets = state.ChainHops[0]
|
targets = state.ChainHops[0]
|
||||||
@@ -2084,6 +2464,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
nextTargets = state.ChainHops[i+1]
|
nextTargets = state.ChainHops[i+1]
|
||||||
}
|
}
|
||||||
for _, chainNode := range hop {
|
for _, chainNode := range hop {
|
||||||
|
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes)
|
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return createdChains, createdServices, err
|
return createdChains, createdServices, err
|
||||||
@@ -2102,6 +2485,9 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, outNode := range state.OutNodes {
|
for _, outNode := range state.OutNodes {
|
||||||
|
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID])
|
||||||
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
if _, err := h.sendNodeCommand(outNode.NodeID, "AddService", serviceData, true, false); err != nil {
|
||||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||||
@@ -2139,6 +2525,23 @@ func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||||
|
if msg == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "节点不在线") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if strings.Contains(msg, "等待节点响应超时") || strings.Contains(msg, "timeout") || strings.Contains(msg, "超时") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord) (map[string]interface{}, error) {
|
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord) (map[string]interface{}, error) {
|
||||||
fromNode := nodes[fromNodeID]
|
fromNode := nodes[fromNodeID]
|
||||||
if fromNode == nil {
|
if fromNode == nil {
|
||||||
@@ -2310,7 +2713,24 @@ func pickNodeAddressV6(node *nodeRecord) string {
|
|||||||
return strings.TrimSpace(node.ServerIP)
|
return strings.TrimSpace(node.ServerIP)
|
||||||
}
|
}
|
||||||
|
|
||||||
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int) (int, error) {
|
func isRemoteNodeTx(tx *sql.Tx, nodeID int64) (bool, error) {
|
||||||
|
if tx == nil {
|
||||||
|
return false, errors.New("database unavailable")
|
||||||
|
}
|
||||||
|
if nodeID <= 0 {
|
||||||
|
return false, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
var isRemote int
|
||||||
|
if err := tx.QueryRow(`SELECT is_remote FROM node WHERE id = ? LIMIT 1`, nodeID).Scan(&isRemote); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return false, errors.New("节点不存在")
|
||||||
|
}
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return isRemote == 1, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int, excludeTunnelID int64) (int, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return 0, errors.New("database unavailable")
|
return 0, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
@@ -2334,7 +2754,13 @@ func pickNodePortTx(tx *sql.Tx, nodeID int64, allocated map[int64]int) (int, err
|
|||||||
}
|
}
|
||||||
|
|
||||||
used := map[int]struct{}{}
|
used := map[int]struct{}{}
|
||||||
chainRows, err := tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL`, nodeID)
|
var chainRows *sql.Rows
|
||||||
|
var err error
|
||||||
|
if excludeTunnelID > 0 {
|
||||||
|
chainRows, err = tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL AND tunnel_id != ?`, nodeID, excludeTunnelID)
|
||||||
|
} else {
|
||||||
|
chainRows, err = tx.Query(`SELECT port FROM chain_tunnel WHERE node_id = ? AND port IS NOT NULL`, nodeID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
@@ -2437,7 +2863,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
|||||||
port := asInt(n["port"], 0)
|
port := asInt(n["port"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
var pickErr error
|
var pickErr error
|
||||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated)
|
port, pickErr = pickNodePortTx(tx, nodeID, allocated, 0)
|
||||||
if pickErr != nil {
|
if pickErr != nil {
|
||||||
return pickErr
|
return pickErr
|
||||||
}
|
}
|
||||||
@@ -2458,7 +2884,7 @@ func replaceTunnelChainsTx(tx *sql.Tx, tunnelID int64, req map[string]interface{
|
|||||||
port := asInt(n["port"], 0)
|
port := asInt(n["port"], 0)
|
||||||
if port <= 0 {
|
if port <= 0 {
|
||||||
var pickErr error
|
var pickErr error
|
||||||
port, pickErr = pickNodePortTx(tx, nodeID, allocated)
|
port, pickErr = pickNodePortTx(tx, nodeID, allocated, 0)
|
||||||
if pickErr != nil {
|
if pickErr != nil {
|
||||||
return pickErr
|
return pickErr
|
||||||
}
|
}
|
||||||
@@ -2481,6 +2907,7 @@ func (h *Handler) deleteNodeByID(id int64) error {
|
|||||||
defer func() { _ = tx.Rollback() }()
|
defer func() { _ = tx.Rollback() }()
|
||||||
_, _ = tx.Exec(`DELETE FROM forward_port WHERE node_id = ?`, id)
|
_, _ = tx.Exec(`DELETE FROM forward_port WHERE node_id = ?`, id)
|
||||||
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE node_id = ?`, id)
|
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE node_id = ?`, id)
|
||||||
|
_, _ = tx.Exec(`DELETE FROM federation_tunnel_binding WHERE node_id = ?`, id)
|
||||||
_, err = tx.Exec(`DELETE FROM node WHERE id = ?`, id)
|
_, err = tx.Exec(`DELETE FROM node WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -2499,6 +2926,7 @@ func (h *Handler) deleteTunnelByID(id int64) error {
|
|||||||
_, _ = tx.Exec(`DELETE FROM user_tunnel WHERE tunnel_id = ?`, id)
|
_, _ = tx.Exec(`DELETE FROM user_tunnel WHERE tunnel_id = ?`, id)
|
||||||
_, _ = tx.Exec(`DELETE FROM speed_limit WHERE tunnel_id = ?`, id)
|
_, _ = tx.Exec(`DELETE FROM speed_limit WHERE tunnel_id = ?`, id)
|
||||||
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id)
|
_, _ = tx.Exec(`DELETE FROM chain_tunnel WHERE tunnel_id = ?`, id)
|
||||||
|
_, _ = tx.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, id)
|
||||||
_, err = tx.Exec(`DELETE FROM tunnel WHERE id = ?`, id)
|
_, err = tx.Exec(`DELETE FROM tunnel WHERE id = ?`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -85,6 +85,12 @@ func shouldSkip(path string) bool {
|
|||||||
return true
|
return true
|
||||||
case path == "/api/v1/federation/tunnel/create":
|
case path == "/api/v1/federation/tunnel/create":
|
||||||
return true
|
return true
|
||||||
|
case path == "/api/v1/federation/runtime/reserve-port":
|
||||||
|
return true
|
||||||
|
case path == "/api/v1/federation/runtime/apply-role":
|
||||||
|
return true
|
||||||
|
case path == "/api/v1/federation/runtime/release-role":
|
||||||
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,6 +124,41 @@ type PeerShare struct {
|
|||||||
AllowedDomains string `json:"allowedDomains"`
|
AllowedDomains string `json:"allowedDomains"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PeerShareRuntime struct {
|
||||||
|
ID int64
|
||||||
|
ShareID int64
|
||||||
|
NodeID int64
|
||||||
|
ReservationID string
|
||||||
|
ResourceKey string
|
||||||
|
BindingID string
|
||||||
|
Role string
|
||||||
|
ChainName string
|
||||||
|
ServiceName string
|
||||||
|
Protocol string
|
||||||
|
Strategy string
|
||||||
|
Port int
|
||||||
|
Target string
|
||||||
|
Applied int
|
||||||
|
Status int
|
||||||
|
CreatedTime int64
|
||||||
|
UpdatedTime int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type FederationTunnelBinding struct {
|
||||||
|
ID int64
|
||||||
|
TunnelID int64
|
||||||
|
NodeID int64
|
||||||
|
ChainType int
|
||||||
|
HopInx int
|
||||||
|
RemoteURL string
|
||||||
|
ResourceKey string
|
||||||
|
RemoteBindingID string
|
||||||
|
AllocatedPort int
|
||||||
|
Status int
|
||||||
|
CreatedTime int64
|
||||||
|
UpdatedTime int64
|
||||||
|
}
|
||||||
|
|
||||||
func Open(path string) (*Repository, error) {
|
func Open(path string) (*Repository, error) {
|
||||||
if err := ensureParentDir(path); err != nil {
|
if err := ensureParentDir(path); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1342,6 +1377,186 @@ func (r *Repository) ListPeerShares() ([]PeerShare, error) {
|
|||||||
return shares, nil
|
return shares, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetPeerShareRuntimeByResourceKey(shareID int64, resourceKey string) (*PeerShareRuntime, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||||
|
FROM peer_share_runtime
|
||||||
|
WHERE share_id = ? AND resource_key = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, shareID, resourceKey)
|
||||||
|
var item PeerShareRuntime
|
||||||
|
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetPeerShareRuntimeByReservationID(shareID int64, reservationID string) (*PeerShareRuntime, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||||
|
FROM peer_share_runtime
|
||||||
|
WHERE share_id = ? AND reservation_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, shareID, reservationID)
|
||||||
|
var item PeerShareRuntime
|
||||||
|
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) GetPeerShareRuntimeByBindingID(shareID int64, bindingID string) (*PeerShareRuntime, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
row := r.db.QueryRow(`
|
||||||
|
SELECT id, share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time
|
||||||
|
FROM peer_share_runtime
|
||||||
|
WHERE share_id = ? AND binding_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, shareID, bindingID)
|
||||||
|
var item PeerShareRuntime
|
||||||
|
if err := row.Scan(&item.ID, &item.ShareID, &item.NodeID, &item.ReservationID, &item.ResourceKey, &item.BindingID, &item.Role, &item.ChainName, &item.ServiceName, &item.Protocol, &item.Strategy, &item.Port, &item.Target, &item.Applied, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) CreatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
return errors.New("runtime item is nil")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
INSERT INTO peer_share_runtime(share_id, node_id, reservation_id, resource_key, binding_id, role, chain_name, service_name, protocol, strategy, port, target, applied, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, item.ShareID, item.NodeID, item.ReservationID, item.ResourceKey, item.BindingID, item.Role, item.ChainName, item.ServiceName, item.Protocol, item.Strategy, item.Port, item.Target, item.Applied, item.Status, item.CreatedTime, item.UpdatedTime)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpdatePeerShareRuntime(item *PeerShareRuntime) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
return errors.New("runtime item is nil")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
UPDATE peer_share_runtime
|
||||||
|
SET binding_id = ?, role = ?, chain_name = ?, service_name = ?, protocol = ?, strategy = ?, port = ?, target = ?, applied = ?, status = ?, updated_time = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`, item.BindingID, item.Role, item.ChainName, item.ServiceName, item.Protocol, item.Strategy, item.Port, item.Target, item.Applied, item.Status, item.UpdatedTime, item.ID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) MarkPeerShareRuntimeReleased(id int64, updatedTime int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`UPDATE peer_share_runtime SET status = 0, updated_time = ? WHERE id = ?`, updatedTime, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListActivePeerShareRuntimePorts(shareID int64, nodeID int64) ([]int, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
rows, err := r.db.Query(`SELECT port FROM peer_share_runtime WHERE share_id = ? AND node_id = ? AND status = 1 AND port > 0`, shareID, nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]int, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var port int
|
||||||
|
if err := rows.Scan(&port); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if port > 0 {
|
||||||
|
out = append(out, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) UpsertFederationTunnelBinding(item *FederationTunnelBinding) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
if item == nil {
|
||||||
|
return errors.New("binding item is nil")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`
|
||||||
|
INSERT INTO federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(tunnel_id, node_id, chain_type, hop_inx)
|
||||||
|
DO UPDATE SET
|
||||||
|
remote_url = excluded.remote_url,
|
||||||
|
resource_key = excluded.resource_key,
|
||||||
|
remote_binding_id = excluded.remote_binding_id,
|
||||||
|
allocated_port = excluded.allocated_port,
|
||||||
|
status = excluded.status,
|
||||||
|
updated_time = excluded.updated_time
|
||||||
|
`, item.TunnelID, item.NodeID, item.ChainType, item.HopInx, item.RemoteURL, item.ResourceKey, item.RemoteBindingID, item.AllocatedPort, item.Status, item.CreatedTime, item.UpdatedTime)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListActiveFederationTunnelBindingsByTunnel(tunnelID int64) ([]FederationTunnelBinding, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
rows, err := r.db.Query(`
|
||||||
|
SELECT id, tunnel_id, node_id, chain_type, hop_inx, remote_url, resource_key, remote_binding_id, allocated_port, status, created_time, updated_time
|
||||||
|
FROM federation_tunnel_binding
|
||||||
|
WHERE tunnel_id = ? AND status = 1
|
||||||
|
ORDER BY chain_type ASC, hop_inx ASC, id ASC
|
||||||
|
`, tunnelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]FederationTunnelBinding, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var item FederationTunnelBinding
|
||||||
|
if err := rows.Scan(&item.ID, &item.TunnelID, &item.NodeID, &item.ChainType, &item.HopInx, &item.RemoteURL, &item.ResourceKey, &item.RemoteBindingID, &item.AllocatedPort, &item.Status, &item.CreatedTime, &item.UpdatedTime); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) DeleteFederationTunnelBindingsByTunnel(tunnelID int64) error {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
_, err := r.db.Exec(`DELETE FROM federation_tunnel_binding WHERE tunnel_id = ?`, tunnelID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
var osMkdirAll = func(path string) error {
|
var osMkdirAll = func(path string) error {
|
||||||
return os.MkdirAll(path, 0o755)
|
return os.MkdirAll(path, 0o755)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,3 +202,43 @@ CREATE TABLE IF NOT EXISTS peer_share (
|
|||||||
allowed_domains TEXT DEFAULT ''
|
allowed_domains TEXT DEFAULT ''
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS peer_share_runtime (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
share_id INTEGER NOT NULL,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
reservation_id TEXT NOT NULL UNIQUE,
|
||||||
|
resource_key TEXT NOT NULL UNIQUE,
|
||||||
|
binding_id TEXT NOT NULL DEFAULT '',
|
||||||
|
role TEXT NOT NULL DEFAULT '',
|
||||||
|
chain_name TEXT NOT NULL DEFAULT '',
|
||||||
|
service_name TEXT NOT NULL DEFAULT '',
|
||||||
|
protocol TEXT NOT NULL DEFAULT 'tls',
|
||||||
|
strategy TEXT NOT NULL DEFAULT 'round',
|
||||||
|
port INTEGER NOT NULL DEFAULT 0,
|
||||||
|
target TEXT NOT NULL DEFAULT '',
|
||||||
|
applied INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_share_node_status ON peer_share_runtime(share_id, node_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_peer_share_runtime_binding_id ON peer_share_runtime(binding_id);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS federation_tunnel_binding (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
tunnel_id INTEGER NOT NULL,
|
||||||
|
node_id INTEGER NOT NULL,
|
||||||
|
chain_type INTEGER NOT NULL,
|
||||||
|
hop_inx INTEGER NOT NULL DEFAULT 0,
|
||||||
|
remote_url TEXT NOT NULL,
|
||||||
|
resource_key TEXT NOT NULL UNIQUE,
|
||||||
|
remote_binding_id TEXT NOT NULL,
|
||||||
|
allocated_port INTEGER NOT NULL,
|
||||||
|
status INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_time INTEGER NOT NULL,
|
||||||
|
updated_time INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status);
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/security"
|
||||||
|
"go-backend/internal/store/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFederationDualPanelMiddleExitAutoPortContract(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", "198.51.100.11", "43000-43010", "provider-entry-secret", 1)
|
||||||
|
providerMiddleNodeID := insertContractNode(t, providerRepo, "provider-middle", "198.51.100.12", "44000-44010", "provider-middle-secret", 1)
|
||||||
|
providerExitNodeID := insertContractNode(t, providerRepo, "provider-exit", "198.51.100.13", "45000-45010", "provider-exit-secret", 1)
|
||||||
|
|
||||||
|
entryShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||||
|
Name: "entry-share",
|
||||||
|
NodeID: providerEntryNodeID,
|
||||||
|
Token: "share-entry-token",
|
||||||
|
PortRangeStart: 43000,
|
||||||
|
PortRangeEnd: 43010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
middleShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||||
|
Name: "middle-share",
|
||||||
|
NodeID: providerMiddleNodeID,
|
||||||
|
Token: "share-middle-token",
|
||||||
|
PortRangeStart: 44000,
|
||||||
|
PortRangeEnd: 44010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
exitShareID := insertPeerShare(t, providerRepo, &sqlite.PeerShare{
|
||||||
|
Name: "exit-share",
|
||||||
|
NodeID: providerExitNodeID,
|
||||||
|
Token: "share-exit-token",
|
||||||
|
PortRangeStart: 45000,
|
||||||
|
PortRangeEnd: 45010,
|
||||||
|
IsActive: 1,
|
||||||
|
CreatedTime: now,
|
||||||
|
UpdatedTime: now,
|
||||||
|
})
|
||||||
|
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-entry-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-middle-token")
|
||||||
|
importRemoteNodeForContract(t, consumerRouter, consumerAdminToken, providerServer.URL, "share-exit-token")
|
||||||
|
|
||||||
|
entryRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-entry-token")
|
||||||
|
middleRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-middle-token")
|
||||||
|
exitRemoteNodeID := queryRemoteNodeIDByToken(t, consumerRepo, "share-exit-token")
|
||||||
|
|
||||||
|
stopMiddle := startMockNodeSession(t, providerServer.URL, "provider-middle-secret")
|
||||||
|
defer stopMiddle()
|
||||||
|
stopExit := startMockNodeSession(t, providerServer.URL, "provider-exit-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
|
||||||
|
}
|
||||||
|
|
||||||
|
firstTunnelID := createTunnel("dual-panel-middle-exit-1")
|
||||||
|
|
||||||
|
assertTunnelPortInRange(t, consumerRepo, firstTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||||
|
assertTunnelPortInRange(t, consumerRepo, firstTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||||
|
|
||||||
|
assertCount(t, consumerRepo, `SELECT COUNT(1) FROM federation_tunnel_binding WHERE tunnel_id = ? AND status = 1`, firstTunnelID, 2)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||||
|
|
||||||
|
deleteBody, err := json.Marshal(map[string]interface{}{"id": firstTunnelID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal delete payload: %v", err)
|
||||||
|
}
|
||||||
|
deleteReq := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/delete", bytes.NewReader(deleteBody))
|
||||||
|
deleteReq.Header.Set("Authorization", consumerAdminToken)
|
||||||
|
deleteReq.Header.Set("Content-Type", "application/json")
|
||||||
|
deleteRes := httptest.NewRecorder()
|
||||||
|
consumerRouter.ServeHTTP(deleteRes, deleteReq)
|
||||||
|
assertCode(t, deleteRes, 0)
|
||||||
|
|
||||||
|
assertCount(t, consumerRepo, `SELECT COUNT(1) FROM federation_tunnel_binding WHERE tunnel_id = ?`, firstTunnelID, 0)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 0`, middleShareID, 1)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 0`, exitShareID, 1)
|
||||||
|
|
||||||
|
secondTunnelID := createTunnel("dual-panel-middle-exit-2")
|
||||||
|
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 2, middleRemoteNodeID, 44000, 44010)
|
||||||
|
assertTunnelPortInRange(t, consumerRepo, secondTunnelID, 3, exitRemoteNodeID, 45000, 45010)
|
||||||
|
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, middleShareID, 1)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ? AND status = 1 AND applied = 1`, exitShareID, 1)
|
||||||
|
assertCount(t, providerRepo, `SELECT COUNT(1) FROM peer_share_runtime WHERE share_id = ?`, entryShareID, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertContractNode(t *testing.T, repo *sqlite.Repository, name, ip, portRange, secret string, status int) int64 {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
res, err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, name, secret, ip, ip, "", portRange, "", "v1", 1, 1, 1, now, now, status, "[::]", "[::]", 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("insert node %s: %v", name, err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("node id %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertPeerShare(t *testing.T, repo *sqlite.Repository, share *sqlite.PeerShare) int64 {
|
||||||
|
t.Helper()
|
||||||
|
if share == nil {
|
||||||
|
t.Fatalf("share is nil")
|
||||||
|
}
|
||||||
|
if err := repo.CreatePeerShare(share); err != nil {
|
||||||
|
t.Fatalf("create peer share %s: %v", share.Name, err)
|
||||||
|
}
|
||||||
|
saved, err := repo.GetPeerShareByToken(share.Token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query peer share %s: %v", share.Name, err)
|
||||||
|
}
|
||||||
|
if saved == nil {
|
||||||
|
t.Fatalf("peer share %s not found after create", share.Name)
|
||||||
|
}
|
||||||
|
return saved.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func importRemoteNodeForContract(t *testing.T, router http.Handler, adminToken, remoteURL, token string) {
|
||||||
|
t.Helper()
|
||||||
|
body, err := json.Marshal(map[string]string{
|
||||||
|
"remoteUrl": remoteURL,
|
||||||
|
"token": token,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal import payload: %v", err)
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/federation/node/import", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
assertCode(t, res, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func queryRemoteNodeIDByToken(t *testing.T, repo *sqlite.Repository, token string) int64 {
|
||||||
|
t.Helper()
|
||||||
|
var id int64
|
||||||
|
if err := repo.DB().QueryRow(`SELECT id FROM node WHERE is_remote = 1 AND remote_token = ? ORDER BY id DESC LIMIT 1`, token).Scan(&id); err != nil {
|
||||||
|
t.Fatalf("query remote node by token %s: %v", token, err)
|
||||||
|
}
|
||||||
|
if id <= 0 {
|
||||||
|
t.Fatalf("invalid remote node id for token %s", token)
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertTunnelPortInRange(t *testing.T, repo *sqlite.Repository, tunnelID int64, chainType int, nodeID int64, minPort int, maxPort int) {
|
||||||
|
t.Helper()
|
||||||
|
var port int
|
||||||
|
err := repo.DB().QueryRow(`
|
||||||
|
SELECT port
|
||||||
|
FROM chain_tunnel
|
||||||
|
WHERE tunnel_id = ? AND chain_type = ? AND node_id = ?
|
||||||
|
LIMIT 1
|
||||||
|
`, tunnelID, chainType, nodeID).Scan(&port)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("query tunnel=%d chainType=%d node=%d port: %v", tunnelID, chainType, nodeID, err)
|
||||||
|
}
|
||||||
|
if port < minPort || port > maxPort {
|
||||||
|
t.Fatalf("expected port in range [%d,%d], got %d", minPort, maxPort, port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertCount(t *testing.T, repo *sqlite.Repository, query string, arg interface{}, expected int) {
|
||||||
|
t.Helper()
|
||||||
|
var got int
|
||||||
|
if err := repo.DB().QueryRow(query, arg).Scan(&got); err != nil {
|
||||||
|
t.Fatalf("count query failed: %v", err)
|
||||||
|
}
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("expected count %d, got %d (query: %s, arg: %v)", expected, got, query, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMockNodeSession(t *testing.T, baseURL string, nodeSecret string) func() {
|
||||||
|
t.Helper()
|
||||||
|
u, err := url.Parse(baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse provider url: %v", err)
|
||||||
|
}
|
||||||
|
if strings.EqualFold(u.Scheme, "https") {
|
||||||
|
u.Scheme = "wss"
|
||||||
|
} else {
|
||||||
|
u.Scheme = "ws"
|
||||||
|
}
|
||||||
|
u.Path = "/system-info"
|
||||||
|
q := u.Query()
|
||||||
|
q.Set("type", "1")
|
||||||
|
q.Set("secret", nodeSecret)
|
||||||
|
q.Set("version", "v1")
|
||||||
|
q.Set("http", "1")
|
||||||
|
q.Set("tls", "1")
|
||||||
|
q.Set("socks", "1")
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dial mock node websocket: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for {
|
||||||
|
_, raw, readErr := conn.ReadMessage()
|
||||||
|
if readErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := raw
|
||||||
|
var wrap struct {
|
||||||
|
Encrypted bool `json:"encrypted"`
|
||||||
|
Data string `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
|
||||||
|
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
|
||||||
|
if cryptoErr == nil {
|
||||||
|
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
|
||||||
|
plain = []byte(dec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cmd struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
RequestID string `json:"requestId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(plain, &cmd); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(cmd.RequestID) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
respType := fmt.Sprintf("%sResponse", cmd.Type)
|
||||||
|
respBytes, err := json.Marshal(map[string]interface{}{
|
||||||
|
"type": respType,
|
||||||
|
"success": true,
|
||||||
|
"message": "OK",
|
||||||
|
"requestId": cmd.RequestID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
}
|
||||||
+23
-9
@@ -10,22 +10,36 @@ Web management console for FLVX (formerly Flux Panel).
|
|||||||
```
|
```
|
||||||
vite-frontend/
|
vite-frontend/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── pages/ # Route views (some very large single-file pages)
|
│ ├── api/ # Axios wrapper + typed endpoint helpers
|
||||||
│ ├── components/ # Reusable UI parts
|
│ ├── components/ # Shared UI components (HeroUI based)
|
||||||
│ ├── layouts/ # Admin vs H5 layouts
|
|
||||||
│ ├── api/ # API functions + axios wrapper
|
|
||||||
│ ├── config/ # Site config (title, repo, version)
|
│ ├── config/ # Site config (title, repo, version)
|
||||||
│ └── utils/ # Auth/JWT + WebView helpers
|
│ ├── layouts/ # Admin vs H5 page chrome
|
||||||
|
│ ├── pages/ # Route views (many large single-file pages)
|
||||||
|
│ ├── utils/ # Auth/JWT + WebView helpers
|
||||||
|
│ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
||||||
|
│ ├── main.tsx # ReactDOM + Providers (HeroUI, Theme, Toast)
|
||||||
|
│ └── provider.tsx # Context provider wrapper
|
||||||
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
├── vite.config.ts # base '/', host 0.0.0.0:3000; build minify/treeshake disabled
|
||||||
├── eslint.config.mjs # ESLint 9 flat config
|
├── eslint.config.mjs # ESLint 9 flat config
|
||||||
└── package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## WHERE TO LOOK
|
||||||
|
| Task | Location | Notes |
|
||||||
|
|------|----------|-------|
|
||||||
|
| **Route definitions** | `src/App.tsx` | React Router v6; H5 detection logic |
|
||||||
|
| **API Client** | `src/api/network.ts` | Sets `Authorization` header (raw token) |
|
||||||
|
| **Endpoint Calls** | `src/api/index.ts` | Thin `Network.post` wrappers |
|
||||||
|
| **Login Flow** | `src/pages/index.tsx` | Calls `login()`, stores `localStorage.token` |
|
||||||
|
| **Auth Logic** | `src/utils/auth.ts` | `isAdmin()` checks `role_id == 0` |
|
||||||
|
| **Token Decoding** | `src/utils/jwt.ts` | Checks `exp` vs current time |
|
||||||
|
| **WebView Logic** | `src/utils/panel.ts` | Handles panel address selection in app mode |
|
||||||
|
|
||||||
## CONVENTIONS
|
## CONVENTIONS
|
||||||
- **Routing**: React Router v6 routes in `vite-frontend/src/App.tsx`.
|
- **Auth**: JWT stored as `localStorage.token`. Sent in `Authorization` header (no "Bearer" prefix).
|
||||||
- **Auth**: JWT stored as `localStorage.token`; sent as `Authorization` header (no prefix) in `vite-frontend/src/api/network.ts`.
|
- **API**: Default base URL is `/api/v1/`.
|
||||||
- **Base URL**: Defaults to `/api/v1/` (or `VITE_API_BASE`); WebView mode selects a panel address via `vite-frontend/src/utils/panel.ts`.
|
- **WebView**: In WebView mode, base URL is derived from selected panel address. If unset, API returns `code: -1`.
|
||||||
- **UI**: HeroUI provider + theme + toast wired in `vite-frontend/src/provider.tsx`.
|
- **Routing**: URL query param `h5=true` forces mobile layout.
|
||||||
|
|
||||||
## COMMANDS
|
## COMMANDS
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
# VITE FRONTEND (src) KNOWLEDGE BASE
|
|
||||||
|
|
||||||
## OVERVIEW
|
|
||||||
React app entry + routing + providers. This is where UI architecture decisions live.
|
|
||||||
|
|
||||||
## STRUCTURE
|
|
||||||
```
|
|
||||||
vite-frontend/src/
|
|
||||||
├── main.tsx # ReactDOM + BrowserRouter + Provider
|
|
||||||
├── provider.tsx # HeroUI + theme + toaster + i18n wrapper
|
|
||||||
├── App.tsx # Routes + ProtectedRoute + H5 layout selection
|
|
||||||
├── api/ # Axios wrapper + typed endpoint helpers
|
|
||||||
├── pages/ # Route views (large)
|
|
||||||
├── layouts/ # Admin/H5 page chrome
|
|
||||||
├── components/ # Shared UI components
|
|
||||||
├── utils/ # JWT parsing + auth helpers + WebView utilities
|
|
||||||
└── styles/ # globals.css
|
|
||||||
```
|
|
||||||
|
|
||||||
## WHERE TO LOOK
|
|
||||||
| Task | Location | Notes |
|
|
||||||
|------|----------|-------|
|
|
||||||
| Route definitions | `vite-frontend/src/App.tsx` | React Router v6 |
|
|
||||||
| API client baseURL | `vite-frontend/src/api/network.ts` | `/api/v1/` + token header |
|
|
||||||
| Token decoding | `vite-frontend/src/utils/jwt.ts` | Checks `exp` vs now |
|
|
||||||
| Role checks | `vite-frontend/src/utils/auth.ts` | `isAdmin()` is `role_id == 0` |
|
|
||||||
| WebView integration | `vite-frontend/src/api/network.ts` | Panel address selection in WebView mode |
|
|
||||||
|
|
||||||
## CONVENTIONS
|
|
||||||
- Token is stored in `localStorage.token` and sent as `Authorization` header (raw token string).
|
|
||||||
- H5 mode detection is in `vite-frontend/src/App.tsx` (screen/user-agent/query param `h5=true`).
|
|
||||||
|
|
||||||
## COMMANDS
|
|
||||||
```bash
|
|
||||||
cd vite-frontend
|
|
||||||
npm run dev
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
# VITE FRONTEND (src/api) KNOWLEDGE BASE
|
|
||||||
|
|
||||||
## OVERVIEW
|
|
||||||
API client layer. Wraps axios and normalizes backend responses (`{ code, msg, data }`).
|
|
||||||
|
|
||||||
## WHERE TO LOOK
|
|
||||||
| Task | Location | Notes |
|
|
||||||
|------|----------|-------|
|
|
||||||
| Axios wrapper | `vite-frontend/src/api/network.ts` | Sets `axios.defaults.baseURL`; adds `Authorization` header |
|
|
||||||
| BaseURL init (WebView vs web) | `vite-frontend/src/api/network.ts` | WebView mode calls `getPanelAddresses()` |
|
|
||||||
| Endpoint functions | `vite-frontend/src/api/index.ts` | Mostly `Network.post("/…")` |
|
|
||||||
|
|
||||||
## CONVENTIONS
|
|
||||||
- Default baseURL is `/api/v1/` (or `${VITE_API_BASE}/api/v1/`).
|
|
||||||
- In WebView mode, baseURL is derived from the selected panel address; if unset, requests return `code: -1` with a “set panel address” message.
|
|
||||||
- 401 responses clear localStorage and redirect to `/`.
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# VITE FRONTEND (pages) KNOWLEDGE BASE
|
|
||||||
|
|
||||||
## OVERVIEW
|
|
||||||
Route views rendered by `vite-frontend/src/App.tsx`. Several pages are large, single-file screens.
|
|
||||||
|
|
||||||
## STRUCTURE
|
|
||||||
```
|
|
||||||
vite-frontend/src/pages/
|
|
||||||
├── index.tsx # Login + captcha flow
|
|
||||||
├── dashboard.tsx
|
|
||||||
├── forward.tsx # Large
|
|
||||||
├── tunnel.tsx # Large
|
|
||||||
├── node.tsx # Large
|
|
||||||
├── user.tsx # Large
|
|
||||||
├── config.tsx
|
|
||||||
├── limit.tsx
|
|
||||||
├── profile.tsx
|
|
||||||
├── settings.tsx
|
|
||||||
└── change-password.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
## WHERE TO LOOK
|
|
||||||
| Task | Location | Notes |
|
|
||||||
|------|----------|-------|
|
|
||||||
| Login flow | `vite-frontend/src/pages/index.tsx` | Calls `login()` and stores `localStorage.token` |
|
|
||||||
| API calls | `vite-frontend/src/api/index.ts` | Thin wrappers around `Network.post` |
|
|
||||||
| Token expiration behavior | `vite-frontend/src/api/network.ts` | Clears localStorage + redirects on 401 |
|
|
||||||
|
|
||||||
## CONVENTIONS
|
|
||||||
- Pages call API wrappers from `vite-frontend/src/api/index.ts` (most endpoints are POST).
|
|
||||||
Reference in New Issue
Block a user