refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
+13
View File
@@ -0,0 +1,13 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
const (
errOriginAddressRequired = "源站地址不能为空"
errOriginAddressInvalid = "源站地址格式不合法"
errOriginAddressExists = "源站地址已存在"
errOriginDeleteReferenced = "该源站仍被规则引用,无法删除"
errOriginMissingPort = "源站地址缺少端口"
errOriginNotFound = "源站不存在"
)
+87
View File
@@ -0,0 +1,87 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"errors"
"fmt"
"net"
"net/url"
"strings"
"unicode"
)
func normalizeOriginAddress(raw string) string {
return strings.ToLower(strings.TrimSpace(raw))
}
func validateOriginAddress(address string) error {
if address == "" {
return errors.New(errOriginAddressRequired)
}
if strings.Contains(address, "://") || strings.ContainsAny(address, "/?#") {
return errors.New(errOriginAddressInvalid)
}
if strings.HasPrefix(address, "[") || strings.HasSuffix(address, "]") {
return errors.New(errOriginAddressInvalid)
}
if ip := net.ParseIP(address); ip != nil {
return nil
}
if len(address) > 253 {
return errors.New(errOriginAddressInvalid)
}
labels := strings.Split(address, ".")
for _, label := range labels {
if len(label) == 0 || len(label) > 63 {
return errors.New(errOriginAddressInvalid)
}
if label[0] == '-' || label[len(label)-1] == '-' {
return errors.New(errOriginAddressInvalid)
}
for _, r := range label {
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' {
continue
}
return errors.New(errOriginAddressInvalid)
}
}
return nil
}
func normalizeOriginName(name string, address string) string {
normalized := strings.TrimSpace(name)
if normalized != "" {
return normalized
}
return address
}
func formatOriginHost(address string, port string) string {
return net.JoinHostPort(address, port)
}
func rewriteOriginURLAddress(rawURL string, newAddress string) (string, error) {
parsed, err := url.ParseRequestURI(strings.TrimSpace(rawURL))
if err != nil {
return "", fmt.Errorf("%s: %w", errOriginAddressInvalid, err)
}
address := normalizeOriginAddress(newAddress)
if err := validateOriginAddress(address); err != nil {
return "", err
}
port := parsed.Port()
if port == "" {
return "", errors.New(errOriginMissingPort)
}
parsed.Host = formatOriginHost(address, port)
return parsed.String(), nil
}
func isUniqueConstraintError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unique")
}
+229
View File
@@ -0,0 +1,229 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"gorm.io/gorm"
)
// Input 源站创建/更新请求。
type Input struct {
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
}
// RouteSummary 源站详情中的代理规则摘要。
type RouteSummary struct {
ID uint `json:"id"`
Domain string `json:"domain"`
OriginURL string `json:"origin_url"`
Enabled bool `json:"enabled"`
UpdatedAt string `json:"updated_at"`
}
// View 源站列表项。
type View struct {
ID uint `json:"id"`
Name string `json:"name"`
Address string `json:"address"`
Remark string `json:"remark"`
RouteCount int64 `json:"route_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// DetailView 源站详情。
type DetailView struct {
View
Routes []RouteSummary `json:"routes"`
}
// ListOrigins 列出全部源站。
func ListOrigins(ctx context.Context) ([]View, error) {
origins, err := model.ListOrigins(ctx)
if err != nil {
return nil, err
}
return buildOriginViews(ctx, origins)
}
// GetOriginDetail 获取源站详情。
func GetOriginDetail(ctx context.Context, id uint) (*DetailView, error) {
origin, err := model.GetOriginByID(ctx, id)
if err != nil {
return nil, err
}
views, err := buildOriginViews(ctx, []model.Origin{*origin})
if err != nil {
return nil, err
}
routes, err := model.ListProxyRoutesByOriginID(ctx, id)
if err != nil {
return nil, err
}
items := make([]RouteSummary, 0, len(routes))
for _, route := range routes {
items = append(items, RouteSummary{
ID: route.ID,
Domain: route.Domain,
OriginURL: route.OriginURL,
Enabled: route.Enabled,
UpdatedAt: route.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Domain < items[j].Domain
})
return &DetailView{
View: views[0],
Routes: items,
}, nil
}
// CreateOrigin 创建源站。
func CreateOrigin(ctx context.Context, input Input) (*model.Origin, error) {
origin, err := buildOrigin(nil, input)
if err != nil {
return nil, err
}
if err = model.CreateOriginRecord(ctx, origin); err != nil {
if isUniqueConstraintError(err) {
return nil, errors.New(errOriginAddressExists)
}
return nil, err
}
return origin, nil
}
// UpdateOrigin 更新源站。
func UpdateOrigin(ctx context.Context, id uint, input Input) (*model.Origin, error) {
origin, err := model.GetOriginByID(ctx, id)
if err != nil {
return nil, err
}
previousAddress := origin.Address
nextOrigin, err := buildOrigin(origin, input)
if err != nil {
return nil, err
}
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Save(nextOrigin).Error; err != nil {
if isUniqueConstraintError(err) {
return errors.New(errOriginAddressExists)
}
return err
}
if previousAddress == nextOrigin.Address {
return nil
}
return updateRoutesForOriginAddress(ctx, tx, nextOrigin.ID, nextOrigin.Address)
})
if err != nil {
return nil, err
}
return nextOrigin, nil
}
// DeleteOrigin 删除源站。
func DeleteOrigin(ctx context.Context, id uint) error {
count, err := model.CountProxyRoutesByOriginID(ctx, id)
if err != nil {
return err
}
if count > 0 {
return errors.New(errOriginDeleteReferenced)
}
if _, err = model.GetOriginByID(ctx, id); err != nil {
return err
}
return model.DeleteOriginRecord(ctx, id)
}
func buildOrigin(existing *model.Origin, input Input) (*model.Origin, error) {
address := normalizeOriginAddress(input.Address)
if err := validateOriginAddress(address); err != nil {
return nil, err
}
if existing == nil {
existing = &model.Origin{}
}
existing.Address = address
existing.Name = normalizeOriginName(input.Name, address)
existing.Remark = strings.TrimSpace(input.Remark)
return existing, nil
}
func buildOriginViews(ctx context.Context, origins []model.Origin) ([]View, error) {
countRows, err := model.ListOriginRouteCounts(ctx)
if err != nil {
return nil, err
}
countMap := make(map[uint]int64, len(countRows))
for _, row := range countRows {
countMap[row.OriginID] = row.RouteCount
}
views := make([]View, 0, len(origins))
for _, origin := range origins {
views = append(views, View{
ID: origin.ID,
Name: origin.Name,
Address: origin.Address,
Remark: origin.Remark,
RouteCount: countMap[origin.ID],
CreatedAt: origin.CreatedAt.Format("2006-01-02T15:04:05Z07:00"),
UpdatedAt: origin.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
}
return views, nil
}
func updateRoutesForOriginAddress(ctx context.Context, tx *gorm.DB, originID uint, address string) error {
if !model.HasProxyRoutesTable(ctx) {
return nil
}
var routes []model.OriginProxyRoute
if err := tx.Where("origin_id = ?", originID).Order("id asc").Find(&routes).Error; err != nil {
return fmt.Errorf("query routes for origin update failed: %w", err)
}
for _, route := range routes {
rewrittenOriginURL, err := rewriteOriginURLAddress(route.OriginURL, address)
if err != nil {
return fmt.Errorf("rewrite route %d origin failed: %w", route.ID, err)
}
upstreams := make([]string, 0)
if strings.TrimSpace(route.Upstreams) != "" {
if err := json.Unmarshal([]byte(route.Upstreams), &upstreams); err != nil {
return fmt.Errorf("decode route %d upstreams failed: %w", route.ID, err)
}
}
if len(upstreams) == 0 {
upstreams = append(upstreams, rewrittenOriginURL)
} else {
upstreams[0] = rewrittenOriginURL
}
upstreamsJSON, err := json.Marshal(upstreams)
if err != nil {
return fmt.Errorf("encode route %d upstreams failed: %w", route.ID, err)
}
if err := tx.Model(&model.OriginProxyRoute{}).
Where("id = ?", route.ID).
Updates(map[string]any{
"origin_url": rewrittenOriginURL,
"upstreams": string(upstreamsJSON),
}).Error; err != nil {
return fmt.Errorf("update route %d origin address failed: %w", route.ID, err)
}
}
return nil
}
@@ -0,0 +1,80 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"context"
"testing"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func setupOriginTestDB(t *testing.T) func() {
t.Helper()
sqliteDB, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
DisableForeignKeyConstraintWhenMigrating: true,
})
require.NoError(t, err)
require.NoError(t, sqliteDB.AutoMigrate(&model.Origin{}))
db.SetDB(sqliteDB)
return func() {
db.SetDB(nil)
}
}
func TestCreateOrigin(t *testing.T) {
cleanup := setupOriginTestDB(t)
defer cleanup()
ctx := context.Background()
origin, err := CreateOrigin(ctx, Input{
Name: "Primary Origin",
Address: "origin-a.internal",
Remark: "main upstream",
})
require.NoError(t, err)
assert.NotZero(t, origin.ID)
assert.Equal(t, "Primary Origin", origin.Name)
assert.Equal(t, "origin-a.internal", origin.Address)
assert.Equal(t, "main upstream", origin.Remark)
_, err = CreateOrigin(ctx, Input{
Address: "origin-a.internal",
})
require.Error(t, err)
assert.Equal(t, errOriginAddressExists, err.Error())
}
func TestListOrigins(t *testing.T) {
cleanup := setupOriginTestDB(t)
defer cleanup()
ctx := context.Background()
first, err := CreateOrigin(ctx, Input{
Name: "first-origin",
Address: "origin-a.internal",
})
require.NoError(t, err)
second, err := CreateOrigin(ctx, Input{
Name: "second-origin",
Address: "origin-b.internal",
})
require.NoError(t, err)
origins, err := ListOrigins(ctx)
require.NoError(t, err)
require.Len(t, origins, 2)
assert.Equal(t, second.ID, origins[0].ID)
assert.Equal(t, first.ID, origins[1].ID)
assert.Equal(t, int64(0), origins[0].RouteCount)
assert.Equal(t, int64(0), origins[1].RouteCount)
}
+141
View File
@@ -0,0 +1,141 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package origin
import (
"net/http"
"github.com/Rain-kl/Wavelet/internal/apps/openflare/apiutil"
"github.com/Rain-kl/Wavelet/internal/common/response"
"github.com/gin-gonic/gin"
)
func handleLogicError(c *gin.Context, err error) bool {
if err == nil {
return false
}
return apiutil.AbortNotFoundIfMissing(c, err, errOriginNotFound)
}
// GetOrigins 列出全部源站。
// @Summary 获取源站列表
// @Description 返回所有源站及关联代理规则数量,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]origin.View} "源站列表"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/origins [get]
func GetOrigins(c *gin.Context) {
origins, err := ListOrigins(c.Request.Context())
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(origins))
}
// GetOrigin 获取源站详情。
// @Summary 获取源站详情
// @Description 返回指定源站信息及关联代理规则摘要,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Success 200 {object} response.Any{data=origin.DetailView} "源站详情"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/d/origins/{id} [get]
func GetOrigin(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
detail, err := GetOriginDetail(c.Request.Context(), id)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(detail))
}
// CreateOriginHandler 创建源站。
// @Summary 创建源站
// @Description 创建新的上游源站记录,需要管理员权限
// @Tags openflare-origin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param body body origin.Input true "源站参数"
// @Success 200 {object} response.Any{data=origin.View} "创建成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或不存在"
// @Router /api/v1/d/origins [post]
func CreateOriginHandler(c *gin.Context) {
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
origin, err := CreateOrigin(c.Request.Context(), input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(origin))
}
// UpdateOriginHandler 更新源站。
// @Summary 更新源站
// @Description 更新指定源站的配置信息,需要管理员权限
// @Tags openflare-origin
// @Accept json
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Param body body origin.Input true "源站参数"
// @Success 200 {object} response.Any{data=origin.View} "更新成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/d/origins/{id}/update [post]
func UpdateOriginHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
var input Input
if !apiutil.BindJSON(c, &input) {
return
}
origin, err := UpdateOrigin(c.Request.Context(), id, input)
if handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(origin))
}
// DeleteOriginHandler 删除源站。
// @Summary 删除源站
// @Description 删除指定源站记录,需要管理员权限
// @Tags openflare-origin
// @Produce json
// @Security SessionCookie
// @Param id path int true "源站 ID"
// @Success 200 {object} response.Any{data=string} "删除成功"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "未登录"
// @Failure 404 {object} response.Any "无权限或源站不存在"
// @Router /api/v1/d/origins/{id}/delete [post]
func DeleteOriginHandler(c *gin.Context) {
id, ok := apiutil.IDParam(c)
if !ok {
return
}
if err := DeleteOrigin(c.Request.Context(), id); handleLogicError(c, err) {
return
}
c.JSON(http.StatusOK, response.OKNil())
}